diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 2f70dcd4d5..330f2a3da4 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -84,6 +84,7 @@ enum ApiEndpoints { ARRAY_ELEMENTS = 'array/elements', ARRAY_AGGREGATE = 'array/aggregate', ARRAY_SEARCH = 'array/search', + ARRAY_SET_ELEMENT = 'array/set-element', 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..a7f41a1b8d 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 @@ -31,10 +31,16 @@ const ArrayDetails = (props: Props) => { - + - + diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.spec.tsx index ada1c368b1..9e9528300e 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.spec.tsx @@ -1,5 +1,11 @@ import React from 'react' -import { render, screen } from 'uiSrc/utils/test-utils' +import { cloneDeep } from 'lodash' +import { + initialStateDefault, + mockStore, + render, + screen, +} from 'uiSrc/utils/test-utils' import { stringToBuffer } from 'uiSrc/utils' import { ArrayAggregateOperation } from 'uiSrc/slices/interfaces/array' @@ -28,8 +34,10 @@ const mockUseArrayAggregateQuery = jest.fn( (..._args: unknown[]) => baseHookResult, ) +const mockAggregateFormProps = jest.fn() jest.mock('../array-aggregate-form', () => ({ - ArrayAggregateForm: () => { + ArrayAggregateForm: (props: { disabled?: boolean }) => { + mockAggregateFormProps(props) const ReactLib = require('react') return ReactLib.createElement('div', { 'data-testid': 'array-aggregate-form-mock', @@ -48,13 +56,41 @@ const defaultProps: AggregateTabProps = { keyProp: keyBuffer, } -const renderComponent = (propsOverride: Partial = {}) => - render() +const renderComponent = ( + propsOverride: Partial = {}, + store?: ReturnType, +) => + render( + , + store ? { store } : undefined, + ) describe('AggregateTab', () => { beforeEach(() => { mockUseArrayAggregateQuery.mockReset() mockUseArrayAggregateQuery.mockReturnValue({ ...baseHookResult }) + mockAggregateFormProps.mockClear() + }) + + it('disables the form while the edit/refresh lock is active', () => { + // isRefreshDisabled is true while an inline edit is open or its ARSET is + // in flight; running an AROP then would be aborted+cleared by the edit's + // post-write cleanup, so the form must be disabled. + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.isRefreshDisabled = true + renderComponent({}, mockStore(state)) + + expect(mockAggregateFormProps).toHaveBeenLastCalledWith( + expect.objectContaining({ disabled: true }), + ) + }) + + it('enables the form when the lock is clear and the key is ready', () => { + renderComponent() + + expect(mockAggregateFormProps).toHaveBeenLastCalledWith( + expect.objectContaining({ disabled: false }), + ) }) it('renders the form and an empty results area by default', () => { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.tsx index 1f99fc0165..4cd199921b 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/aggregate-tab/AggregateTab.tsx @@ -1,6 +1,8 @@ import React from 'react' import { noop } from 'lodash' +import { useAppSelector } from 'uiSrc/slices/hooks' +import { selectedKeySelector } from 'uiSrc/slices/browser/keys' import { FlexItem } from 'uiSrc/components/base/layout/flex' import { Loader } from 'uiSrc/components/base/display' import { CopyButton } from 'uiSrc/components/copy-button' @@ -17,6 +19,11 @@ const NIL_RESULT_LABEL = '(nil)' const AggregateTab = ({ keyProp }: AggregateTabProps) => { const keyName = keyProp ? bufferToString(keyProp) : '' + // Same lock the View range form uses: while an inline edit is open or its + // ARSET is in flight, block a new AROP. Otherwise a user-initiated aggregate + // could be aborted+cleared by the edit's post-write cleanup, leaving the tab + // blank (updateArrayElementAction aborts any in-flight AROP on success). + const { isRefreshDisabled } = useAppSelector(selectedKeySelector) const { start, @@ -67,7 +74,7 @@ const AggregateTab = ({ keyProp }: AggregateTabProps) => { onChangeValue={setValue} onRun={runQuery} onReset={resetQuery} - disabled={!isArrayKeyReady} + disabled={!isArrayKeyReady || isRefreshDisabled} /> diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx index 14a896fb0f..9cea0c67e7 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx @@ -30,13 +30,27 @@ const valueColumn: ColumnDef = { enableSorting: false, enableResizing: true, cell: ({ row, table }: CellContext) => { - const { compressor, viewFormat } = table.options.meta as ArrayTableConfig + const { + compressor, + viewFormat, + editingIndex, + onEditElement, + onApplyEditElement, + updating, + loading, + } = table.options.meta as ArrayTableConfig + const { index } = row.original return ( onEditElement(index, isEditing)} + onApply={(value) => onApplyEditElement(index, value)} /> ) }, diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx index 7012a589ac..b0e3119a6a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx @@ -1,6 +1,22 @@ import React from 'react' +import { cloneDeep } from 'lodash' +import { combineReducers, configureStore } from '@reduxjs/toolkit' import userEvent from '@testing-library/user-event' -import { render, screen } from 'uiSrc/utils/test-utils' +import { + act, + fireEvent, + initialStateDefault, + mockStore, + render, + screen, + waitFor, +} from 'uiSrc/utils/test-utils' +import { apiService } from 'uiSrc/services' +import keysReducer, { + refreshKeyInfoSuccess, + setSelectedKeyRefreshDisabled, +} from 'uiSrc/slices/browser/keys' +import { stringToBuffer } from 'uiSrc/utils' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' import { arrayElementFactory, @@ -9,13 +25,27 @@ import { import { ArrayDetailsTable } from './ArrayDetailsTable' +// Store whose selected key is set but whose array `data.keyName` is still +// empty — the Search-tab / pre-View-load condition the edit key must survive. +const storeWithSelectedKey = (name: string) => { + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.data = { name } as any + state.browser.array.data.keyName = '' + return mockStore(state) +} + const renderComponent = ( elements: ArrayDataElement[], loading: boolean = false, error?: string, ) => render( - , + , ) describe('ArrayDetailsTable', () => { @@ -94,12 +124,365 @@ describe('ArrayDetailsTable', () => { expect(screen.getByText('No elements in range')).toBeInTheDocument() }) + describe('inline value edit (ARSET)', () => { + it('reveals an edit affordance for a populated value and enters edit mode', () => { + renderComponent([arrayElementWithValueFactory.build({ index: '1' })]) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + + expect( + screen.getByTestId('array-details-table_value-editor-1'), + ).toBeInTheDocument() + }) + + // A read that writes a patched view must block editing while in flight, so + // its late response can't overwrite the optimistic patch — and this holds + // across tabs (the View table must see a Search loading and vice-versa), + // so it's driven from the slice, not the per-tab `loading` prop. + it.each([ + ['range/scan', (s: any) => (s.browser.array.loading = true)], + ['search', (s: any) => (s.browser.array.search.loading = true)], + ])('disables editing while a %s read is in flight', (_name, setLoading) => { + const state = cloneDeep(initialStateDefault) + setLoading(state) + const store = mockStore(state) + + render( + , + { store }, + ) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + + expect( + screen.getByTestId('array-details-table_edit-btn-1'), + ).toBeDisabled() + }) + + it('does not offer editing for an empty slot', () => { + renderComponent([arrayElementFactory.build({ index: '3' })]) + + expect( + screen.queryByTestId('array-details-table_edit-btn-3'), + ).not.toBeInTheDocument() + expect( + screen.getByTestId('array-details-table-empty-3'), + ).toBeInTheDocument() + }) + + it('dispatches ARSET set-element when an edit is applied', async () => { + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + + renderComponent([arrayElementWithValueFactory.build({ index: '1' })]) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + + fireEvent.change( + screen.getByTestId('array-details-table_value-editor-1'), + { target: { value: 'updated' } }, + ) + fireEvent.click(screen.getByTestId('apply-btn')) + + await waitFor(() => { + const setCall = postSpy.mock.calls.find(([url]) => + (url as string).includes('array/set-element'), + ) + expect(setCall).toBeTruthy() + expect((setCall?.[1] as { index: string }).index).toBe('1') + }) + + postSpy.mockRestore() + }) + + it('uses the selected key name for ARSET even when the View range has not loaded', async () => { + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + const store = storeWithSelectedKey('mykey') + + render( + , + { store }, + ) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.change( + screen.getByTestId('array-details-table_value-editor-1'), + { target: { value: 'updated' } }, + ) + fireEvent.click(screen.getByTestId('apply-btn')) + + await waitFor(() => { + const setCall = postSpy.mock.calls.find(([url]) => + (url as string).includes('array/set-element'), + ) + expect((setCall?.[1] as { keyName: string }).keyName).toBe('mykey') + }) + + postSpy.mockRestore() + }) + + it('keeps the open editor when a same-key info refresh swaps the name buffer', () => { + // A successful ARSET dispatches `refreshKeyInfoAction`, and + // `refreshKeyInfoSuccess` replaces `selectedKey.data` with a *new* name + // buffer instance for the unchanged key. A store that runs the real + // `keys` reducer is needed to reproduce the swap (`mockStore` doesn't run + // reducers); the other branches the table reads are held constant. + const keys = cloneDeep(initialStateDefault.browser.keys) + keys.selectedKey.data = { name: stringToBuffer('mykey') } as any + const store = configureStore({ + reducer: combineReducers({ + browser: combineReducers({ + keys: keysReducer, + array: (s = initialStateDefault.browser.array) => s, + }), + connections: combineReducers({ + instances: (s = initialStateDefault.connections.instances) => s, + }), + }), + preloadedState: { browser: { keys } }, + middleware: (getDefault) => + getDefault({ serializableCheck: false, immutableCheck: false }), + }) + + render( + , + { store }, + ) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + expect( + screen.getByTestId('array-details-table_value-editor-1'), + ).toBeInTheDocument() + + // Same key, fresh buffer instance — the editor must survive the refresh. + act(() => { + store.dispatch(refreshKeyInfoSuccess({ name: stringToBuffer('mykey') })) + }) + + expect( + screen.getByTestId('array-details-table_value-editor-1'), + ).toBeInTheDocument() + }) + + it('a stale save does not close an editor reopened as a new session', async () => { + // Guard passes (same key + instance) so the thunk would call onSuccess; + // the edit-session token is what must stop it closing the new editor. + const key = stringToBuffer('mykey') + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.data = { name: key } as any + state.app.context.browser.keyList.selectedKey = key + state.connections.instances.connectedInstance = { id: 'db-1' } as any + const store = mockStore(state) + + let resolvePost: () => void = () => {} + const postSpy = jest.spyOn(apiService, 'post').mockImplementation( + () => + new Promise((r) => { + resolvePost = () => r({ status: 200, data: '' } as any) + }), + ) + const element = arrayElementWithValueFactory.build({ index: '1' }) + const props = { elements: [element], loading: false } + + const { rerender } = render(, { + store, + }) + + // Session 1: open and apply — the ARSET stays in flight. + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.change( + screen.getByTestId('array-details-table_value-editor-1'), + { target: { value: 'first' } }, + ) + fireEvent.click(screen.getByTestId('apply-btn')) + + // Switch away (closes the editor) and back, then reopen — session 2. + rerender() + rerender() + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + expect( + screen.getByTestId('array-details-table_value-editor-1'), + ).toBeInTheDocument() + + // The first (stale) save resolves — it must not close the new editor. + await act(async () => { + resolvePost() + }) + + expect( + screen.getByTestId('array-details-table_value-editor-1'), + ).toBeInTheDocument() + + postSpy.mockRestore() + }) + + it('re-enables the key-header refresh when unmounted mid-edit', () => { + const store = mockStore(cloneDeep(initialStateDefault)) + + const { unmount } = render( + , + { store }, + ) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + expect(store.getActions()).toContainEqual( + setSelectedKeyRefreshDisabled(true), + ) + + unmount() + + // The last refresh-disabled action must be `false` — without an unmount + // cleanup it would remain `true` and the header refresh would stay + // disabled after the panel/tab goes away with an editor still open. + const refreshActions = store + .getActions() + .filter((a) => a.type === setSelectedKeyRefreshDisabled(false).type) + expect(refreshActions.at(-1)).toEqual( + setSelectedKeyRefreshDisabled(false), + ) + }) + + it('drops the editor and re-enables refresh when its tab is hidden', () => { + const store = mockStore(cloneDeep(initialStateDefault)) + const element = arrayElementWithValueFactory.build({ index: '1' }) + + const { rerender } = render( + , + { store }, + ) + + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + expect( + screen.getByTestId('array-details-table_value-editor-1'), + ).toBeInTheDocument() + + // Switch to another tab: this table is no longer the visible one. + rerender( + , + ) + + // Editor is abandoned and refresh released — a hidden tab must not keep + // the header refresh disabled (tabs stay mounted via display:none). + expect( + screen.queryByTestId('array-details-table_value-editor-1'), + ).not.toBeInTheDocument() + const refreshActions = store + .getActions() + .filter((a) => a.type === setSelectedKeyRefreshDisabled(false).type) + expect(refreshActions.at(-1)).toEqual( + setSelectedKeyRefreshDisabled(false), + ) + }) + + it('keeps refresh disabled while editing, even with a hidden sibling table mounted', () => { + const store = mockStore(cloneDeep(initialStateDefault)) + const element = arrayElementWithValueFactory.build({ index: '1' }) + + // Both View (active) and Search (hidden) mount a table from the same key. + render( + <> + + + , + { store }, + ) + + // Open the editor in the active (first) table. + act(() => { + fireEvent.mouseEnter( + screen.getAllByTestId('array-details-table_content-value-1')[0], + ) + }) + fireEvent.click( + screen.getAllByTestId('array-details-table_edit-btn-1')[0], + ) + + // The hidden sibling must not re-enable refresh during the edit. + const refreshActions = store + .getActions() + .filter((a) => a.type === setSelectedKeyRefreshDisabled(false).type) + expect(refreshActions.at(-1)).toEqual(setSelectedKeyRefreshDisabled(true)) + }) + }) + it('renders an expanded panel when a row is expanded via row click', async () => { const user = userEvent.setup() render( true} renderExpandedRow={(row) => ( @@ -118,6 +501,7 @@ describe('ArrayDetailsTable', () => { , ) expect(screen.queryByTestId('expanded-7')).not.toBeInTheDocument() @@ -137,6 +521,7 @@ describe('ArrayDetailsTable', () => { , ) @@ -149,6 +534,7 @@ describe('ArrayDetailsTable', () => { , ) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx index b44be88873..ab3713a6fd 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx @@ -1,10 +1,26 @@ -import React, { memo, useMemo } from 'react' -import { useAppSelector } from 'uiSrc/slices/hooks' +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { + selectedKeyDataSelector, + selectedKeySelector, + setSelectedKeyRefreshDisabled, +} from 'uiSrc/slices/browser/keys' +import { + arraySelector, + isSameKey, + updateArrayElementAction, +} from 'uiSrc/slices/browser/array' import { KeyValueCompressor } from 'uiSrc/constants' -import { Nullable } from 'uiSrc/utils' +import { Nullable, stringToSerializedBufferFormat } from 'uiSrc/utils' import { ARRAY_TABLE_EMPTY_MESSAGE, @@ -24,30 +40,157 @@ import * as S from './ArrayDetailsTable.styles' /** * Renders the array slice's currently-loaded `elements` through the - * redis-ui `Table` (`@redis-ui/table`). Shows a per-row delete affordance - * when the consumer passes `deleteConfig`; otherwise the table is read-only. + * redis-ui `Table` (`@redis-ui/table`). Populated values are editable in + * place (ARSET) via the value cell's inline editor; empty slots stay + * read-only (see ArrayValueCell). Shows a per-row delete affordance when the + * consumer passes `deleteConfig`. */ const ArrayDetailsTable = memo( ({ elements, loading, error, + isActive, renderExpandedRow, getIsRowExpandable, expandRowOnClick, deleteConfig, }: ArrayDetailsTableProps) => { + const dispatch = useAppDispatch() const { compressor = null } = useAppSelector( connectedInstanceSelector, ) as unknown as { compressor: Nullable } const { viewFormat } = useAppSelector(selectedKeySelector) + const { + updating, + loading: rangeLoading, + search, + } = useAppSelector(arraySelector) + // Block opening an edit while any read that writes a patched view is in + // flight — range/scan (data.elements) or search (search.data), from either + // tab — so a late response can't overwrite the optimistic patch. Read from + // the slice, not the `loading` prop, so the View table also sees a search + // loading on the hidden Search tab (and vice-versa). + const readLoading = rangeLoading || search.loading + // Use the selected key's name, not the array slice's `data.keyName` — + // the latter is only set after a View range/scan succeeds, but this table + // is also rendered by the Search tab, so an edit there (or before View + // loads) would otherwise POST ARSET with an empty key. + const { name: keyName } = useAppSelector(selectedKeyDataSelector) ?? { + name: '', + } + + // Index of the row currently being edited; only one row edits at a time. + const [editingIndex, setEditingIndex] = useState>(null) + // Identifies the current edit session. Bumped whenever an editor opens, so + // a still-in-flight save from a previous session can't close an editor the + // user has since reopened (which would discard the new input). + const editSessionRef = useRef(0) + + // Only the visible tab's table drives the editor-driven refresh pause, so + // a hidden table can't re-enable refresh while the active one has an editor + // open. Stays paused until the editor closes AND the ARSET settles, so a + // stale reload can't overwrite the optimistic patch mid-write. (This effect + // intentionally omits `editingIndex` from the inactive path — only the + // active table reacts to it.) + useEffect(() => { + if (!isActive) return + dispatch(setSelectedKeyRefreshDisabled(editingIndex !== null || updating)) + }, [isActive, editingIndex, updating, dispatch]) + + // When a table is hidden (tab switch) or unmounts, it releases the shared + // flag but still respects an in-flight write (global), so switching to a + // tab without a table can't leave refresh stuck disabled. + useEffect(() => { + if (isActive) return + dispatch(setSelectedKeyRefreshDisabled(updating)) + }, [isActive, updating, dispatch]) + + // Abandon an open editor when this table is hidden (tab switch) or the key + // changes, so a background editor can't keep refresh disabled and a stale + // editing state can't carry over. + useEffect(() => { + if (!isActive) setEditingIndex(null) + }, [isActive]) + + // Abandon an open editor only on a *real* key change. `keyName` is the + // selected key's name buffer, and the post-ARSET `refreshKeyInfoAction` + // swaps in a new buffer instance for the same key — comparing by value + // (not reference) stops that refresh from closing an editor the user has + // meanwhile reopened on another row. + const prevKeyRef = useRef(keyName) + useEffect(() => { + if (isSameKey(prevKeyRef.current, keyName)) return + prevKeyRef.current = keyName + setEditingIndex(null) + }, [keyName]) + + // Re-enable refresh when the table unmounts entirely (panel close). + useEffect( + () => () => { + dispatch(setSelectedKeyRefreshDisabled(false)) + }, + [dispatch], + ) + + const handleEditElement = useCallback( + (index: string, isEditing: boolean) => { + // Opening an editor starts a new session; a stale save's callback that + // compares against its captured session id will then no-op. + if (isEditing) editSessionRef.current += 1 + setEditingIndex(isEditing ? index : null) + }, + [], + ) + + const handleApplyEditElement = useCallback( + (index: string, value: string) => { + const editSession = editSessionRef.current + dispatch( + updateArrayElementAction( + { + key: keyName, + index, + value: stringToSerializedBufferFormat(viewFormat, value), + }, + () => { + // Ignore a completion whose editor the user has since closed and + // reopened (a newer session) — closing it would discard the new + // input. handleEditElement's own guard runs for the live session. + if (editSessionRef.current === editSession) { + handleEditElement(index, false) + } + }, + ), + ) + }, + [dispatch, keyName, viewFormat, handleEditElement], + ) // Pass shared per-cell config via the table's `meta` so the static // column defs in `ArrayDetailsTable.config` don't need to close over - // them and can be rebuilt only when `compressor` / `viewFormat` change. + // them and can be rebuilt only when their inputs change. const meta = useMemo( - () => ({ compressor, viewFormat, deleteConfig }), - [compressor, viewFormat, deleteConfig], + () => ({ + compressor, + viewFormat, + editingIndex, + onEditElement: handleEditElement, + onApplyEditElement: handleApplyEditElement, + updating, + loading: readLoading, + deleteConfig, + }), + [ + compressor, + viewFormat, + editingIndex, + handleEditElement, + handleApplyEditElement, + updating, + readLoading, + deleteConfig, + ], ) // The delete column is appended only when the consumer opts in, so the diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts index 6ed844d7be..2228ab6d55 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts @@ -13,6 +13,10 @@ export interface ArrayDetailsTableProps { * doesn't misleadingly read "No elements in range" when the request * errored. The slice still also raises a toast via `addErrorNotification`. */ error?: string + /** True when this table is in the visible tab. Both View and Search mount + * a table at once, so only the active one drives the shared key-header + * refresh flag; an inactive table also abandons any open editor. */ + isActive: boolean /** Search context band only. Renders an expanded panel under each row; * omitted on the View / Aggregate tabs, which then show no expand * affordance. */ @@ -32,6 +36,19 @@ export interface ArrayDetailsTableProps { export interface ArrayTableConfig { compressor: Nullable viewFormat: KeyValueFormat + /** Index of the row currently in edit mode, or null when none is. */ + editingIndex: Nullable + /** Open / close inline edit for a row's value. */ + onEditElement: (index: string, isEditing: boolean) => void + /** Persist an edited value (plain string from the editor) via ARSET. */ + onApplyEditElement: (index: string, value: string) => void + /** True while an ARSET write is in flight — keeps the editor in its loading + * state and blocks a second edit from overlapping the request. */ + updating: boolean + /** True while a read that writes a patched view (range/scan or search) is in + * flight — blocks opening an edit so a late response can't overwrite the + * optimistic patch. */ + loading: boolean /** Present only when the consumer enables row deletion; the actions cell * reads it from the table `meta`. */ deleteConfig?: ArrayElementDeleteConfig diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx new file mode 100644 index 0000000000..8be5cd032b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { render, screen } from 'uiSrc/utils/test-utils' +import { stringToBuffer } from 'uiSrc/utils' +import { KeyValueFormat } from 'uiSrc/constants' + +import { ArrayValueCell } from './ArrayValueCell' + +const renderCell = (props: Record = {}) => + render( + , + ) + +describe('ArrayValueCell — open editor Save lock', () => { + it('enables Save when no write/read is in flight', () => { + renderCell({ updating: false, loading: false }) + expect(screen.getByTestId('apply-btn')).not.toBeDisabled() + }) + + it('disables Save while a patched-view read is in flight', () => { + // A range/scan/search read that started just before the refresh-disabled + // flag took effect must not be Saved into — its success would overwrite the + // optimistic patch. + renderCell({ updating: false, loading: true }) + expect(screen.getByTestId('apply-btn')).toBeDisabled() + }) + + it('disables Save while an ARSET write is in flight', () => { + renderCell({ updating: true, loading: false }) + expect(screen.getByTestId('apply-btn')).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx index 43d343e231..11bc16879d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx @@ -1,10 +1,29 @@ import React from 'react' import { Text } from 'uiSrc/components/base/text' -import { TEXT_FAILED_CONVENT_FORMATTER } from 'uiSrc/constants' -import { createTooltipContent, formattingBuffer } from 'uiSrc/utils' +import { + TEXT_DISABLED_COMPRESSED_VALUE, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_FAILED_CONVENT_FORMATTER, + TEXT_INVALID_VALUE, + TEXT_UNPRINTABLE_CHARACTERS, +} from 'uiSrc/constants' +import { + bufferToSerializedFormat, + bufferToString, + createTooltipContent, + formattingBuffer, + isEqualBuffers, + isFormatEditable, + isNonUnicodeFormatter, + stringToBuffer, + stringToSerializedBufferFormat, +} from 'uiSrc/utils' import { decompressingBuffer } from 'uiSrc/utils/decompressors' -import { FormattedValue } from 'uiSrc/pages/browser/modules/key-details/shared' +import { + EditableTextArea, + FormattedValue, +} from 'uiSrc/pages/browser/modules/key-details/shared' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { ArrayValueCellProps } from './ArrayValueCell.types' @@ -12,17 +31,21 @@ import { ArrayValueCellProps } from './ArrayValueCell.types' const TEST_ID_PREFIX = 'array-details-table' /** - * Renders a populated array slot's value through the standard buffer → - * decompress → format pipeline. For empty slots (null from ARGETRANGE, - * or an absent `value` field from a serialization edge case) renders a - * muted "(empty)" marker instead of running it through the formatter - * chain. + * Renders a populated slot's value (formatted) wrapped in an inline editor for + * in-place edits (ARSET). Empty slots render a muted "(empty)" marker and are + * not editable — filling a gap changes ARCOUNT/ARLEN and belongs to append / + * set-at-index, not the value edit. */ export const ArrayValueCell = ({ index, value, compressor, viewFormat, + isEditing = false, + updating = false, + loading = false, + onEdit, + onApply, }: ArrayValueCellProps) => { // Treat null and undefined identically — `JSON.stringify` drops keys // whose values are undefined, so an undefined `value` here means the @@ -38,7 +61,10 @@ export const ArrayValueCell = ({ // Values flow through the API in `encoding=buffer` mode, so we narrow // RedisString to RedisResponseBuffer at the rendering boundary. const buffer = value as RedisResponseBuffer - const { value: decompressed } = decompressingBuffer(buffer, compressor) + const { value: decompressed, isCompressed } = decompressingBuffer( + buffer, + compressor, + ) const decompressedBuffer = decompressed as RedisResponseBuffer const { value: formatted, isValid } = formattingBuffer( decompressedBuffer, @@ -51,18 +77,57 @@ export const ArrayValueCell = ({ viewFormat, ) + // Compressed payloads and non-round-trippable formats can't be safely + // edited; values with unprintable characters are disabled in the editor. + const isEditable = !isCompressed && isFormatEditable(viewFormat) + const isUnprintable = + !isNonUnicodeFormatter(viewFormat, isValid) && + !isEqualBuffers(decompressedBuffer, stringToBuffer(bufferToString(buffer))) + const editToolTipContent = isCompressed + ? TEXT_DISABLED_COMPRESSED_VALUE + : TEXT_DISABLED_FORMATTER_EDITING + const serializedValue = isEditing + ? bufferToSerializedFormat(viewFormat, decompressedBuffer, 4) + : '' + return ( -
+ !!formattingBuffer( + stringToSerializedBufferFormat(viewFormat, editedValue), + viewFormat, + )?.isValid + } + editToolTipContent={!isEditable ? editToolTipContent : null} + onEdit={(editing) => onEdit?.(editing)} + onDecline={() => onEdit?.(false)} + onApply={(editedValue) => onApply?.(editedValue)} + field={index} + testIdPrefix={TEST_ID_PREFIX} > - -
+
+ +
+ ) } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.types.ts index 2750b751ad..68f21e3c5e 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.types.ts @@ -7,4 +7,17 @@ export interface ArrayValueCellProps { value: ArrayDataElement['value'] compressor: Nullable viewFormat: KeyValueFormat + /** True when this row's value is currently in edit mode. */ + isEditing?: boolean + /** True while an ARSET write is in flight — shows the editor's loading + * state and blocks opening another edit until it settles. */ + updating?: boolean + /** True while a range/scan request is in flight — blocks opening an edit so + * a late refresh response can't overwrite the optimistic patch. */ + loading?: boolean + /** Toggle edit mode for this row (open via the edit button, close on + * decline / successful apply). Omitted in read-only contexts. */ + onEdit?: (isEditing: boolean) => void + /** Apply the edited value (already formatter-decoded to a plain string). */ + onApply?: (value: string) => void } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx index bf0bd0b9ea..1f0fb8ac22 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx @@ -45,7 +45,7 @@ const buildState = ( const renderTab = (search?: Partial, keyLoading = false) => { const store = mockStore(buildState(search, keyLoading)) store.clearActions() - return render(, { store }) + return render(, { store }) } describe('SearchTab', () => { @@ -55,6 +55,18 @@ describe('SearchTab', () => { expect(screen.getByTestId('array-search-form')).toBeInTheDocument() }) + it('disables the search form while the key is locked for editing', () => { + // isRefreshDisabled is set by the active table while a value editor is open + // or an ARSET is in flight; the query form must not reload the table then. + const state = buildState() + state.browser.keys.selectedKey.isRefreshDisabled = true + const store = mockStore(state) + + render(, { store }) + + expect(screen.getByTestId('array-search-form-run')).toBeDisabled() + }) + it('does not render the results table before a search has run', () => { renderTab({ loaded: false, loading: false, data: [] }) @@ -239,7 +251,7 @@ describe('SearchTab', () => { // The tab stays mounted across key switches; selecting another key resets // Context to its default rather than inheriting the previous key's. - rerender() + rerender() expect(screen.getByRole('checkbox', { name: 'Context' })).not.toBeChecked() }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx index 6f487fe245..e4f1c92e00 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx @@ -14,8 +14,9 @@ import * as S from '../tabs.styles' import { NeighbourBand } from './NeighbourBand' import { SearchTabProps } from './SearchTab.types' -const SearchTab = ({ keyProp }: SearchTabProps) => { - const { loading: keyLoading } = useAppSelector(selectedKeySelector) +const SearchTab = ({ keyProp, isActive }: SearchTabProps) => { + const { loading: keyLoading, isRefreshDisabled } = + useAppSelector(selectedKeySelector) const keyName = keyProp ? bufferToString(keyProp) : '' // Context is a display concern (±N neighbours on expand), off by default so @@ -85,7 +86,7 @@ const SearchTab = ({ keyProp }: SearchTabProps) => { onChangeContext={onChangeContext} onRun={runSearch} onReset={handleReset} - disabled={!isArrayKeyReady} + disabled={!isArrayKeyReady || isRefreshDisabled} /> {/* Keep the tab blank until the user runs a search, then let @@ -98,6 +99,7 @@ const SearchTab = ({ keyProp }: SearchTabProps) => { elements={elements} loading={loading} error={error} + isActive={isActive} deleteConfig={deleteConfig} expandRowOnClick getIsRowExpandable={() => context.enabled && !!keyProp} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.types.ts index 9739a4f6a9..95ff66bec0 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.types.ts @@ -2,4 +2,7 @@ import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' export interface SearchTabProps { keyProp: RedisResponseBuffer | null + /** True when this is the visible tab — only the active tab's table drives + * the shared key-header refresh flag and keeps its editor open. */ + isActive: boolean } 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 index 66c7c51cd2..58095d0ef3 100644 --- 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 @@ -38,7 +38,7 @@ const buildState = (elements: ArrayDataElement[]) => { const renderTab = (elements: ArrayDataElement[]) => { const store = mockStore(buildState(elements)) store.clearActions() - return render(, { store }) + return render(, { store }) } describe('ViewTab', () => { 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 e8042d17b0..353d08f55b 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 @@ -10,8 +10,8 @@ import { useArrayRangeQuery, useArrayElementActions } from '../hooks' import * as S from '../tabs.styles' import { ViewTabProps } from './ViewTab.types' -const ViewTab = ({ keyProp }: ViewTabProps) => { - const { loading } = useAppSelector(selectedKeySelector) +const ViewTab = ({ keyProp, isActive }: ViewTabProps) => { + const { loading, isRefreshDisabled } = useAppSelector(selectedKeySelector) const keyName = keyProp ? bufferToString(keyProp) : '' const { @@ -48,7 +48,7 @@ const ViewTab = ({ keyProp }: ViewTabProps) => { onToggleShowEmpty={setShowEmpty} onRun={runQuery} onReset={resetQuery} - disabled={!isArrayKeyReady} + disabled={!isArrayKeyReady || isRefreshDisabled} /> {!loading && ( @@ -57,6 +57,7 @@ const ViewTab = ({ keyProp }: ViewTabProps) => { elements={elements} loading={rangeLoading} error={rangeError} + isActive={isActive} deleteConfig={deleteConfig} /> 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..21b324c028 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,7 @@ import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' export interface ViewTabProps { keyProp: RedisResponseBuffer | null + /** True when this is the visible tab — only the active tab's table drives + * the shared key-header refresh flag and keeps its editor open. */ + isActive: boolean } diff --git a/redisinsight/ui/src/slices/browser/array.ts b/redisinsight/ui/src/slices/browser/array.ts index 041b1ced38..7a14b50507 100644 --- a/redisinsight/ui/src/slices/browser/array.ts +++ b/redisinsight/ui/src/slices/browser/array.ts @@ -34,13 +34,14 @@ import { FetchArrayRangeParams, FetchArrayScanParams, SearchArrayParams, + UpdateArrayElementParams, } from 'uiSrc/slices/interfaces/array' import { RedisString, RedisResponseBuffer } from 'uiSrc/slices/interfaces/app' import { + refreshKeyInfoAction, updateSelectedKeyRefreshTime, deleteKeyFromList, deleteSelectedKeySuccess, - refreshKeyInfoAction, } from './keys' import { appContextSelectedKey } from 'uiSrc/slices/app/context' import { AppDispatch, RootState } from '../store' @@ -70,6 +71,7 @@ export const DEFAULT_SCAN_LIMIT = 1_000_000 export const initialState: StateArray = { loading: false, error: '', + updating: false, query: { start: DEFAULT_QUERY_START, end: DEFAULT_QUERY_END, @@ -265,6 +267,36 @@ const arraySlice = createSlice({ resetArraySearch: (state) => { state.search = { ...initialState.search } }, + + // Tracks an in-flight inline ARSET so the table can disable overlapping + // edits and hold the header refresh until the write settles. + setArrayUpdating: (state, { payload }: PayloadAction) => { + state.updating = payload + }, + + // Optimistically reflect a successful ARSET in the loaded page so the + // table updates without a refetch. The View tab renders from + // `data.elements` and the Search tab from `search.data` through the same + // table, so patch the matching index in both. No-op where the edited + // index isn't loaded. + updateArrayElement: ( + state, + { payload }: PayloadAction<{ index: string; value: RedisString }>, + ) => { + const patch = (elements: ArrayDataElement[]) => { + const target = elements.find( + (element) => element.index === payload.index, + ) + if (target) target.value = payload.value + } + patch(state.data.elements) + // A WITHVALUES=false search holds index-only rows on purpose; writing a + // value here would surface one the active query never asked to load. + // Leave those rows value-less (a later refetch/replay fills them). + if (state.search.query?.withValues !== false) { + patch(state.search.data) + } + }, }, }) @@ -285,6 +317,8 @@ export const { loadArraySearchSuccess, loadArraySearchFailure, resetArraySearch, + updateArrayElement, + setArrayUpdating, } = arraySlice.actions export const arraySelector = (state: RootState) => state.browser.array @@ -492,6 +526,102 @@ export function searchArray(params: SearchArrayParams) { } } +/** + * Monotonic token identifying the most recently started ARSET edit. A key + * switch resets the slice (clearing `updating`) and can let a new edit begin + * before an earlier request settles; the token lets a stale completion skip + * releasing the lock so it can't re-enable refresh/edits for the newer write. + */ +let latestEditRequestToken = 0 + +/** + * Compares two key names by value. In buffer-encoding mode key names are + * `RedisResponseBuffer`s and Redux may swap the instance for the same bytes + * (e.g. a key-info refetch), so byte-compare rather than rely on reference + * identity; fall back to strict equality for plain-string names / nullish + * values. + */ +export const isSameKey = (a?: unknown, b?: unknown): boolean => { + if ( + a == null || + b == null || + typeof a === 'string' || + typeof b === 'string' + ) { + return a === b + } + return isEqualBuffers(a as RedisResponseBuffer, b as RedisResponseBuffer) +} + +// ARSET — in-place value edit. Editing a populated slot can't change +// ARLEN/ARCOUNT, so the header counters are intentionally not refreshed. +// `value` must already be in the formatter's serialized-buffer shape. +export function updateArrayElementAction( + params: UpdateArrayElementParams, + onSuccessAction?: () => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + latestEditRequestToken += 1 + const requestToken = latestEditRequestToken + dispatch(setArrayUpdating(true)) + try { + const state = stateInit() + const startInstanceId = state.connections.instances.connectedInstance?.id + 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)) { + // The user may have switched database or key while the POST was in + // flight. Only patch the table when the edit still belongs to the + // current selection, so a late success can't overwrite a same-index + // row in a different key — or, for a same-named key in another + // database, apply the old connection's value to the new one. + const latest = stateInit() + // Read the live selection from the app context, not selectedKey.data: + // fetchKeyInfo's loading action leaves the previous key's data in place + // while the newly selected key loads, so selectedKey.data lags a switch + // (the delete thunk below guards the same way). + const selectedKey = appContextSelectedKey(latest) + const sameInstance = + latest.connections.instances.connectedInstance?.id === startInstanceId + if (sameInstance && isSameKey(selectedKey, params.key)) { + dispatch( + updateArrayElement({ index: params.index, value: params.value }), + ) + // The edit can change a value a previously-run AROP was computed + // from, so drop the stored aggregate rather than show a stale number + // when the user returns to the (still-mounted) Aggregate tab. Abort + // any in-flight AROP too, or its late success would repopulate the + // result we just cleared. + abortArrayAggregate() + dispatch(clearArrayAggregate()) + // Refetch key info (not just stamp the refresh time): editing a value + // to a different byte length changes the key's Size even though + // ARLEN/ARCOUNT don't — matches the List/Hash/String edit thunks. + dispatch(refreshKeyInfoAction(params.key as RedisResponseBuffer)) + // Only close the editor when this completion still belongs to the + // current selection. A stale success after a key/database switch + // would otherwise close (and discard) an editor the user has since + // opened on the new selection's same-index row. + onSuccessAction?.() + } + } + } catch (error) { + dispatch(addErrorNotification(error as IAddInstanceErrorPayload)) + onFailAction?.() + } finally { + // Only the latest edit releases the lock; a stale completion (e.g. after + // a key switch let a newer edit start) leaves it held for the newer write. + if (requestToken === latestEditRequestToken) { + dispatch(setArrayUpdating(false)) + } + } + } +} + /** * Fetches the ±N context window for one search match via ARGETRANGE and * returns the normalized elements. Writes nothing into the shared array diff --git a/redisinsight/ui/src/slices/interfaces/array.ts b/redisinsight/ui/src/slices/interfaces/array.ts index 305d391cea..9b285fbbe9 100644 --- a/redisinsight/ui/src/slices/interfaces/array.ts +++ b/redisinsight/ui/src/slices/interfaces/array.ts @@ -145,6 +145,9 @@ export interface ArraySearchState { export interface StateArray { loading: boolean error: string + /** True while an inline ARSET edit is in flight, so the table can block + * overlapping edits and keep the header refresh paused until it settles. */ + updating: boolean query: ArrayActiveQuery data: ArrayData aggregate: ArrayAggregateState @@ -216,6 +219,18 @@ export interface ArraySearchOptions { limit: string } +/** + * ARSET single-element edit (Modify vertical). `index` addresses the slot to + * overwrite; `value` is the serialized-buffer payload the formatter pipeline + * expects (built via `stringToSerializedBufferFormat`). The key must already + * exist — this edits a loaded element, it never creates a key. + */ +export interface UpdateArrayElementParams { + key: RedisString + index: string + value: RedisString +} + /** * Re-export the auto-generated SDK response shapes for consumers that need * to pass them around. The slice itself narrows them into `ArrayData` / diff --git a/redisinsight/ui/src/slices/tests/browser/array.spec.ts b/redisinsight/ui/src/slices/tests/browser/array.spec.ts index d9d9f86305..f6c11f38c7 100644 --- a/redisinsight/ui/src/slices/tests/browser/array.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/array.spec.ts @@ -28,6 +28,12 @@ import reducer, { loadArraySearchSuccess, loadArraySearchFailure, resetArraySearch, + updateArrayElement, + setArrayUpdating, + clearArrayAggregate, + loadArrayAggregateSuccess, + aggregateArray, + abortArrayAggregate, arraySelector, arrayDataSelector, arraySearchSelector, @@ -37,11 +43,13 @@ import reducer, { fetchArrayCount, refreshArray, searchArray, + updateArrayElementAction, fetchArrayNeighbours, deleteArrayElements, } from '../../browser/array' import { arrayGrepPredicateFactory } from 'uiSrc/mocks/factories/browser/array/arrayGrepPredicate.factory' import { + refreshKeyInfo, updateSelectedKeyRefreshTime, deleteSelectedKeySuccess, } from '../../browser/keys' @@ -195,6 +203,94 @@ describe('array slice', () => { expect(next.data.count).toBe('7') }) + it('updateArrayElement replaces the value of the matching index only', () => { + const dirty = { + ...initialState, + data: { + ...initialState.data, + elements: [ + { index: '0', value: 'a' }, + { index: '5', value: 'b' }, + ], + }, + } + const next = reducer( + dirty, + updateArrayElement({ index: '5', value: 'B' }), + ) + expect(next.data.elements).toEqual([ + { index: '0', value: 'a' }, + { index: '5', value: 'B' }, + ]) + }) + + it('updateArrayElement is a no-op when the index is not loaded', () => { + const dirty = { + ...initialState, + data: { + ...initialState.data, + elements: [{ index: '0', value: 'a' }], + }, + } + const next = reducer( + dirty, + updateArrayElement({ index: '9', value: 'x' }), + ) + expect(next.data.elements).toEqual([{ index: '0', value: 'a' }]) + }) + + it('setArrayUpdating toggles the in-flight ARSET flag', () => { + const next = reducer(initialState, setArrayUpdating(true)) + expect(next.updating).toBe(true) + expect(reducer(next, setArrayUpdating(false)).updating).toBe(false) + }) + + it('updateArrayElement patches the Search results too (shared table)', () => { + const dirty = { + ...initialState, + data: { + ...initialState.data, + elements: [{ index: '5', value: 'b' }], + }, + search: { + ...initialState.search, + data: [{ index: '5', value: 'b' }], + }, + } + const next = reducer( + dirty, + updateArrayElement({ index: '5', value: 'B' }), + ) + expect(next.data.elements).toEqual([{ index: '5', value: 'B' }]) + // The Search tab renders the same ArrayDetailsTable from search.data, so + // an edit issued there must reflect in the search results too. + expect(next.search.data).toEqual([{ index: '5', value: 'B' }]) + }) + + it('leaves WITHVALUES=false search rows value-less on edit', () => { + const dirty = { + ...initialState, + data: { + ...initialState.data, + elements: [{ index: '5', value: 'b' }], + }, + search: { + ...initialState.search, + data: [{ index: '5', value: null }], + query: { predicates: [], withValues: false } as any, + }, + } + const next = reducer( + dirty, + updateArrayElement({ index: '5', value: 'B' }), + ) + // The View tab reflects the edit… + expect(next.data.elements).toEqual([{ index: '5', value: 'B' }]) + // …but an index-only search result stays value-less, since the active + // query explicitly asked not to load values. + expect(next.search.data).toEqual([{ index: '5', value: null }]) + }) + describe('search sub-state', () => { const dirtySearch = { ...initialState, @@ -713,6 +809,272 @@ describe('array slice', () => { }) }) + describe('updateArrayElementAction', () => { + // The optimistic patch only applies when the edited key is still the + // selected one, so these tests run against a store whose selected key + // matches `mockKey`. The guard reads the live app-context selection + // (updated synchronously on key click), not selectedKey.data. + const storeWithSelectedKey = (name: unknown) => { + const state = cloneDeep(initialStateDefault) + state.app.context.browser.keyList.selectedKey = name as any + const s = mockStore(state) + s.clearActions() + return s + } + + it('posts keyName/index/value and optimistically updates the element', async () => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) + const keyedStore = storeWithSelectedKey(mockKey) + + await keyedStore.dispatch( + updateArrayElementAction({ key: mockKey, index: '5', value: 'B' }), + ) + + const [url, body] = (apiService.post as jest.Mock).mock.calls[0] + expect(url).toContain('array/set-element') + expect(body).toEqual({ keyName: mockKey, index: '5', value: 'B' }) + const actions = keyedStore.getActions() + expect(actions).toContainEqual(setArrayUpdating(true)) + expect(actions).toContainEqual( + updateArrayElement({ index: '5', value: 'B' }), + ) + expect(actions).toContainEqual(clearArrayAggregate()) + // Refetch key info (not just stamp the time) so the header Key Size + // reflects a value edited to a different byte length. + expect(actions).toContainEqual(refreshKeyInfo()) + expect(actions).toContainEqual(setArrayUpdating(false)) + }) + + it('skips the patch and the success callback when the selected key changed mid-write', async () => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) + // User switched to another key before the POST resolved. + const keyedStore = storeWithSelectedKey('another-key') + const onSuccess = jest.fn() + + await keyedStore.dispatch( + updateArrayElementAction( + { key: mockKey, index: '5', value: 'B' }, + onSuccess, + ), + ) + + // No table patch / refresh for the now-current key, and onSuccess must + // NOT fire — closing the editor here would discard an edit the user has + // opened on the new key's same-index row. + expect(keyedStore.getActions()).toEqual([ + setArrayUpdating(true), + setArrayUpdating(false), + ]) + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('skips the UI updates when selectedKey.data still lags on the old key after a switch', async () => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) + // The user switched to another key; the live app-context selection + // updated, but selectedKey.data still holds the edited key while its + // successor loads. Guarding on selectedKey.data would wrongly pass. + const state = cloneDeep(initialStateDefault) + state.app.context.browser.keyList.selectedKey = + stringToBuffer('another-key') + ;(state.browser.keys.selectedKey as any).data = { name: mockKey } + const keyedStore = mockStore(state) + keyedStore.clearActions() + const onSuccess = jest.fn() + + await keyedStore.dispatch( + updateArrayElementAction( + { key: mockKey, index: '5', value: 'B' }, + onSuccess, + ), + ) + + expect(keyedStore.getActions()).toEqual([ + setArrayUpdating(true), + setArrayUpdating(false), + ]) + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('skips the UI updates when the database changed mid-write, even for a same-named key', async () => { + // The POST is sent using the connection captured before the await. If + // the user switches to another database whose selected key has the + // same name, the key-only guard would still pass — so the value + // written to the old database must not be applied to the new one. + const state = cloneDeep(initialStateDefault) + state.app.context.browser.keyList.selectedKey = mockKey as any + state.connections.instances.connectedInstance = { id: 'db-1' } as any + const keyedStore = mockStore(state) + keyedStore.clearActions() + const onSuccess = jest.fn() + + apiService.post = jest.fn().mockImplementation(async () => { + // User switches to another database before the POST resolves. + state.connections.instances.connectedInstance = { id: 'db-2' } as any + return { status: 200, data: '' } + }) + + await keyedStore.dispatch( + updateArrayElementAction( + { key: mockKey, index: '5', value: 'B' }, + onSuccess, + ), + ) + + const actions = keyedStore.getActions() + expect(actions).not.toContainEqual( + updateArrayElement({ index: '5', value: 'B' }), + ) + expect(actions.some((a) => a.type === clearArrayAggregate.type)).toBe( + false, + ) + expect(onSuccess).not.toHaveBeenCalled() + // The lock is still released for the current (latest) write. + expect(actions).toContainEqual(setArrayUpdating(false)) + }) + + it('only the latest ARSET clears the update lock when two overlap', async () => { + const keyedStore = storeWithSelectedKey(mockKey) + let resolveFirst: () => void = () => {} + let resolveSecond: () => void = () => {} + apiService.post = jest + .fn() + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = () => r({ status: 200, data: '' }) + }), + ) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveSecond = () => r({ status: 200, data: '' }) + }), + ) + + const first = keyedStore.dispatch( + updateArrayElementAction({ key: mockKey, index: '1', value: 'a' }), + ) + const second = keyedStore.dispatch( + updateArrayElementAction({ key: mockKey, index: '2', value: 'b' }), + ) + + const isSetUpdatingFalse = (a: { type: string; payload?: unknown }) => + a.type === setArrayUpdating(false).type && a.payload === false + + // The stale first completion must NOT release the lock — a second + // ARSET is still pending. + resolveFirst() + await first + expect(keyedStore.getActions().filter(isSetUpdatingFalse)).toHaveLength( + 0, + ) + + // The latest one clears it. + resolveSecond() + await second + expect(keyedStore.getActions().filter(isSetUpdatingFalse)).toHaveLength( + 1, + ) + }) + + it('still patches when the selected key is the same bytes but a new buffer instance', async () => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) + // Redux can replace the name buffer with a fresh instance for the same + // key (e.g. a key-info refetch) while the POST is in flight. + const keyedStore = storeWithSelectedKey(stringToBuffer('readings')) + + await keyedStore.dispatch( + updateArrayElementAction({ + key: stringToBuffer('readings'), + index: '5', + value: 'B', + }), + ) + + expect(keyedStore.getActions()).toContainEqual( + updateArrayElement({ index: '5', value: 'B' }), + ) + }) + + it('aborts an in-flight aggregate so a stale AROP cannot repopulate after the edit', async () => { + const keyedStore = storeWithSelectedKey(mockKey) + let resolveAgg: () => void = () => {} + apiService.post = jest + .fn() + .mockImplementationOnce( + () => + new Promise((r) => { + resolveAgg = () => + r({ + status: 200, + data: { keyName: mockKey, result: '104.7' }, + }) + }), + ) + .mockResolvedValue({ status: 200, data: '' }) + + // AROP in flight, then an edit lands and clears + aborts it. + const aggregate = keyedStore.dispatch( + aggregateArray({ + key: mockKey, + start: '0', + end: '6', + operation: ArrayAggregateOperation.Sum, + }), + ) + await keyedStore.dispatch( + updateArrayElementAction({ key: mockKey, index: '1', value: 'x' }), + ) + + // The stale aggregate response resolves after the edit. + resolveAgg() + await aggregate + + // It must not repopulate the (cleared) aggregate state. + const types = keyedStore.getActions().map((a) => a.type) + expect(types).not.toContain(loadArrayAggregateSuccess.type) + abortArrayAggregate() + }) + + it('calls onSuccessAction on success', async () => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) + const keyedStore = storeWithSelectedKey(mockKey) + const onSuccess = jest.fn() + + await keyedStore.dispatch( + updateArrayElementAction( + { key: mockKey, index: '5', value: 'B' }, + onSuccess, + ), + ) + + expect(onSuccess).toHaveBeenCalled() + }) + + it('notifies and calls onFailAction on error without touching the table', async () => { + const rejected = { + response: { status: 500, data: { message: 'boom' } }, + } + apiService.post = jest.fn().mockRejectedValue(rejected) + const onFail = jest.fn() + + await store.dispatch( + updateArrayElementAction( + { key: mockKey, index: '5', value: 'B' }, + undefined, + onFail, + ), + ) + + expect(onFail).toHaveBeenCalled() + expect(store.getActions()).toEqual([ + setArrayUpdating(true), + addErrorNotification(rejected as IAddInstanceErrorPayload), + setArrayUpdating(false), + ]) + }) + }) + describe('searchArray', () => { const predicates = arrayGrepPredicateFactory.buildList(1)