Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4b5c283
add ui
pawelangelow Jun 25, 2026
209ea16
fix(ui): address array inline-edit review feedback
pawelangelow Jun 25, 2026
1be9426
fix(ui): harden array inline edit against in-flight and tab races
pawelangelow Jun 25, 2026
3b804fd
fix(ui): guard array edit against stale keys and hidden-tab refresh
pawelangelow Jun 26, 2026
e90fde8
test(ui): pass required isActive prop in SearchTab spec
pawelangelow Jun 26, 2026
da49c6b
test(ui): guard refresh stays disabled with a hidden sibling table
pawelangelow Jun 26, 2026
30d5eeb
fix(ui): scope ARSET update lock and clear stale aggregate on edit
pawelangelow Jun 26, 2026
98c5b26
fix(ui): compare array edit key by bytes, not reference
pawelangelow Jun 26, 2026
2fa16ef
fix(ui): refetch key info after array element edit
pawelangelow Jun 26, 2026
e8c73b8
fix(ui): lock array query forms while a key edit is in progress
pawelangelow Jun 26, 2026
6068bb1
fix(ui): close array edit-vs-read races on refresh and aggregate
pawelangelow Jun 26, 2026
58d9a35
fix(ui): block array edits while any patched-view read is in flight
pawelangelow Jun 26, 2026
887e383
docs(ui): clarify array edit-lock loading covers search too
pawelangelow Jun 26, 2026
35363c2
fix(ui): lock open array editor's Save during an in-flight read
pawelangelow Jun 26, 2026
c5fccf7
Merge branch 'main' into fe/RI-8222/implement-modify-ui
pawelangelow Jul 2, 2026
f6d234e
Merge branch 'main' into fe/RI-8222/implement-modify-ui
pawelangelow Jul 2, 2026
9f7ee35
fix(ui): keep array editor open across same-key info refresh
pawelangelow Jul 2, 2026
3c93325
fix(ui): skip ARSET success callback after an array key switch
pawelangelow Jul 2, 2026
d8e1960
fix(ui): guard array edit success on the connected instance too
pawelangelow Jul 2, 2026
3e40936
fix(ui): guard array edit success on the live selected key
pawelangelow Jul 2, 2026
9d86ea1
fix(ui): don't add values to WITHVALUES-off search results on edit
pawelangelow Jul 2, 2026
5e575d8
fix(ui): disable the aggregate form during an in-flight array edit
pawelangelow Jul 2, 2026
2074a32
fix(ui): don't let a stale edit save close a reopened editor
pawelangelow Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions redisinsight/ui/src/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ enum ApiEndpoints {
ARRAY_GET_COUNT = 'array/get-count',
ARRAY_AGGREGATE = 'array/aggregate',
ARRAY_SEARCH = 'array/search',
ARRAY_SET_ELEMENT = 'array/set-element',

STREAMS = 'streams',
STREAMS_ENTRIES = 'streams/entries',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,16 @@ const ArrayDetails = (props: Props) => {
<ArrayTabs value={activeTab} onChange={setActiveTab} />
</S.TabsWrapper>
<S.TabSlot $hidden={activeTab !== ArrayDetailsTab.View}>
<ViewTab keyProp={keyProp} />
<ViewTab
keyProp={keyProp}
isActive={activeTab === ArrayDetailsTab.View}
/>
</S.TabSlot>
<S.TabSlot $hidden={activeTab !== ArrayDetailsTab.Search}>
<SearchTab keyProp={keyProp} />
<SearchTab
keyProp={keyProp}
isActive={activeTab === ArrayDetailsTab.Search}
/>
</S.TabSlot>
<S.TabSlot $hidden={activeTab !== ArrayDetailsTab.Aggregate}>
<AggregateTab keyProp={keyProp} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,27 @@ const valueColumn: ColumnDef<ArrayDataElement> = {
enableSorting: false,
enableResizing: true,
cell: ({ row, table }: CellContext<ArrayDataElement, unknown>) => {
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 (
<ArrayValueCell
index={row.original.index}
index={index}
value={row.original.value}
compressor={compressor}
viewFormat={viewFormat}
isEditing={editingIndex === index}
updating={updating}
loading={loading}
onEdit={(isEditing) => onEditElement(index, isEditing)}
onApply={(value) => onApplyEditElement(index, value)}
/>
)
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import React from 'react'
import { render, screen } from 'uiSrc/utils/test-utils'
import { cloneDeep } from 'lodash'
import {
act,
fireEvent,
initialStateDefault,
mockStore,
render,
screen,
waitFor,
} from 'uiSrc/utils/test-utils'
import { apiService } from 'uiSrc/services'
import { setSelectedKeyRefreshDisabled } from 'uiSrc/slices/browser/keys'
import { ArrayDataElement } from 'uiSrc/slices/interfaces/array'
import {
arrayElementFactory,
Expand All @@ -8,13 +19,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(
<ArrayDetailsTable elements={elements} loading={loading} error={error} />,
<ArrayDetailsTable
elements={elements}
loading={loading}
error={error}
isActive
/>,
)

describe('ArrayDetailsTable', () => {
Expand Down Expand Up @@ -92,4 +117,242 @@ describe('ArrayDetailsTable', () => {
renderComponent([], false, '')
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(
<ArrayDetailsTable
elements={[arrayElementWithValueFactory.build({ index: '1' })]}
loading={false}
isActive
/>,
{ 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(
<ArrayDetailsTable
elements={[arrayElementWithValueFactory.build({ index: '1' })]}
loading={false}
isActive
/>,
{ 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('re-enables the key-header refresh when unmounted mid-edit', () => {
const store = mockStore(cloneDeep(initialStateDefault))

const { unmount } = render(
<ArrayDetailsTable
elements={[arrayElementWithValueFactory.build({ index: '1' })]}
loading={false}
isActive
/>,
{ 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(
<ArrayDetailsTable elements={[element]} loading={false} isActive />,
{ 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(
<ArrayDetailsTable
elements={[element]}
loading={false}
isActive={false}
/>,
)

// 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(
<>
<ArrayDetailsTable elements={[element]} loading={false} isActive />
<ArrayDetailsTable
elements={[element]}
loading={false}
isActive={false}
/>
</>,
{ 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))
})
})
})
Loading
Loading