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 c2c2668bf5..df5215b626 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,4 +1,5 @@ import React from 'react' +import userEvent from '@testing-library/user-event' import { render, screen } from 'uiSrc/utils/test-utils' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' import { @@ -92,4 +93,33 @@ describe('ArrayDetailsTable', () => { renderComponent([], false, '') expect(screen.getByText('No elements in range')).toBeInTheDocument() }) + + it('renders an expanded panel when a row is expanded via row click', async () => { + const user = userEvent.setup() + render( + true} + renderExpandedRow={(row) => ( +
panel
+ )} + />, + ) + + await user.click(screen.getByTestId('array-details-table-index-7')) + + expect(await screen.findByTestId('expanded-7')).toBeInTheDocument() + }) + + it('renders no expand affordance when expansion props are omitted', () => { + render( + , + ) + expect(screen.queryByTestId('expanded-7')).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts index c6909bfea0..5ba43b995b 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts @@ -1,6 +1,7 @@ import styled from 'styled-components' import { FlexItem } from 'uiSrc/components/base/layout/flex' -import { Table } from 'uiSrc/components/base/layout/table' +import { Table, TableProps } from 'uiSrc/components/base/layout/table' +import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' export const Container = styled(FlexItem)` display: flex; @@ -10,6 +11,8 @@ export const Container = styled(FlexItem)` padding: ${({ theme }) => theme.core?.space.space200}; ` +// `styled(Table)` widens the row generic to `object` and drops the +// `ArrayDataElement`-typed expansion callbacks; the trailing cast pins it back. export const StyledTable = styled(Table)` scrollbar-width: thin; max-height: 100%; @@ -19,4 +22,4 @@ export const StyledTable = styled(Table)` [data-role='table-scroller'] { scrollbar-width: thin; } -` +` as unknown as (props: TableProps) => JSX.Element 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 137691c4ac..7d21d86686 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 @@ -28,7 +28,14 @@ import * as S from './ArrayDetailsTable.styles' * verticals (see docs/redis-array-type-initiative.md §6 Tasks 6-7). */ const ArrayDetailsTable = memo( - ({ elements, loading, error }: ArrayDetailsTableProps) => { + ({ + elements, + loading, + error, + renderExpandedRow, + getIsRowExpandable, + expandRowOnClick, + }: ArrayDetailsTableProps) => { const { compressor = null } = useAppSelector( connectedInstanceSelector, ) as unknown as { compressor: Nullable } @@ -59,6 +66,9 @@ const ArrayDetailsTable = memo( stripedRows minWidth={TABLE_MIN_WIDTH} emptyState={emptyState} + renderExpandedRow={renderExpandedRow} + getIsRowExpandable={getIsRowExpandable} + expandRowOnClick={expandRowOnClick} data-testid={`${TEST_ID}-table`} /> 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 4ee1cd5211..788356d908 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 @@ -1,4 +1,6 @@ +import { ReactNode } from 'react' import { KeyValueCompressor, KeyValueFormat } from 'uiSrc/constants' +import { Row } from 'uiSrc/components/base/layout/table' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' import { Nullable } from 'uiSrc/utils' @@ -9,6 +11,12 @@ 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 + /** Search context band only. Renders an expanded panel under each row; + * omitted on the View / Aggregate tabs, which then show no expand + * affordance. */ + renderExpandedRow?: (row: Row) => ReactNode + getIsRowExpandable?: (rowData: ArrayDataElement) => boolean + expandRowOnClick?: boolean } /** diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts index 4e139d2d6e..396d6f7eb3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts @@ -18,8 +18,7 @@ export const APPLIES_TO_ALL_LABEL = 'applies to all' export const OPTIONS_LABEL = 'Options' export const OPTIONS_HINT = - 'Range limits the index window (blank = whole array). NOCASE matches ' + - 'case-insensitively, WITHVALUES returns values, LIMIT caps the result count.' + 'Refine which elements are searched and how matches are shown.' export const RANGE_LABEL = 'Range' export const RANGE_TO_LABEL = 'to' export const START_PLACEHOLDER = '-' @@ -27,6 +26,17 @@ export const END_PLACEHOLDER = '+' export const NOCASE_LABEL = 'NOCASE' export const WITHVALUES_LABEL = 'WITHVALUES' export const LIMIT_LABEL = 'LIMIT' +export const CONTEXT_LABEL = 'Context' +export const CONTEXT_PREFIX = '±' + +/** Per-option (i) hints rendered next to each control. */ +export const RANGE_HINT = + 'Limits the index window searched (blank = whole array).' +export const NOCASE_HINT = 'Match case-insensitively.' +export const WITHVALUES_HINT = "Return each match's value, not just its index." +export const LIMIT_HINT = 'Cap the number of matches returned.' +export const CONTEXT_HINT = + 'When expanding a match, also show ±N neighbouring elements.' export const INVALID_INDEX_MESSAGE = 'Index must be a valid 64-bit unsigned integer' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx index ac6c6460f2..aecc9f0900 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx @@ -1,5 +1,11 @@ import React from 'react' -import { fireEvent, render, screen, userEvent } from 'uiSrc/utils/test-utils' +import { + fireEvent, + render, + screen, + userEvent, + waitFor, +} from 'uiSrc/utils/test-utils' import { ArrayCombinator, ArrayGrepCriteria, @@ -24,6 +30,8 @@ const defaultProps: ArraySearchFormProps = { onChangePredicate: jest.fn(), onChangeCombinator: jest.fn(), onChangeOptions: jest.fn(), + context: { enabled: false, count: 5 }, + onChangeContext: jest.fn(), onRun: jest.fn(), onReset: jest.fn(), } @@ -184,6 +192,75 @@ describe('ArraySearchForm', () => { }) }) + describe('context', () => { + // The context control lives inside the collapsed Options section, so each + // test must expand it before the controls exist in the DOM. + const openOptions = () => + fireEvent.click(screen.getByTestId(`${TEST_ID}-options-toggle`)) + + it('keeps the context input disabled until the toggle is ticked', () => { + const { rerender } = renderComponent() + openOptions() + // Off by default → input present (so layout is stable) but disabled. + expect(screen.getByTestId(`${TEST_ID}-context`)).toBeDisabled() + + rerender( + , + ) + expect(screen.getByTestId(`${TEST_ID}-context`)).toBeEnabled() + }) + + it('enables context when the toggle is ticked', () => { + const onChangeContext = jest.fn() + renderComponent({ onChangeContext }) + openOptions() + + fireEvent.click(screen.getByTestId(`${TEST_ID}-context-toggle`)) + + expect(onChangeContext).toHaveBeenCalledWith({ enabled: true }) + }) + + it('shows the passed count and clamps a typed value above the max to 50', async () => { + const user = userEvent.setup() + renderComponent({ context: { enabled: true, count: 5 } }) + openOptions() + + const input = screen.getByTestId(`${TEST_ID}-context`) + // redis-ui NumericInput renders a text input, so the DOM value is a + // string. + expect(input).toHaveValue('5') + + // redis-ui's `autoValidate` clamps onChange, but the field text only + // settles to the clamped value on blur — so '99' stays verbatim while + // typing and resolves to '50' once the input blurs. + await user.clear(input) + await user.type(input, '99') + await user.tab() + + await waitFor(() => { + expect(input).toHaveValue('50') + }) + }) + + it('reports a new count via onChangeContext', () => { + const onChangeContext = jest.fn() + renderComponent({ + context: { enabled: true, count: 5 }, + onChangeContext, + }) + openOptions() + + fireEvent.change(screen.getByTestId(`${TEST_ID}-context`), { + target: { value: '8' }, + }) + + expect(onChangeContext).toHaveBeenCalledWith({ count: 8 }) + }) + }) + describe('run', () => { it('calls onRun on click and on Enter in a value input', () => { const onRun = jest.fn() diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.styles.ts index 2f4ebe8ea2..81113fcf59 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.styles.ts @@ -1,5 +1,6 @@ import styled from 'styled-components' import { ToggleButton } from 'uiSrc/components/base/forms/buttons' +import { Checkbox } from 'uiSrc/components/base/forms/checkbox/Checkbox' import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' import { Col, Row } from 'uiSrc/components/base/layout/flex' @@ -31,6 +32,11 @@ export const NarrowInputBox = styled(Row)` width: 110px; ` +/** Hairline rule between the index-window row and the flags row. */ +export const OptionsDivider = styled(Row)` + border-top: 1px solid ${({ theme }) => theme.semantic.color.border.neutral500}; +` + /** * Fixed minimum width so the action row doesn't reflow when the selected * criteria label changes width (Exact / Match / Glob / Regex). @@ -39,6 +45,18 @@ export const CriteriaSelect = styled(RiSelect)` min-width: 85px; ` +/** + * Option checkbox with its label's trailing padding removed so a following + * InfoHint hugs the text. That padding is on the inner `