= {}) =>
+ 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)