Skip to content

Commit 3e9c27f

Browse files
kmerzlinuspahl
andauthored
Fix "Test against stream" dropdown closing on scroll (#25849)
* Fix "Test against stream" dropdown closing on scroll Replace the `DropdownButton`/`MenuItem` implementation with `SelectPopover`, which uses `OverlayTrigger` with `rootClose` (closes on click-outside only, not on scroll). This fixes the UX issue where scrolling a long stream list would immediately close the dropdown. Also adds a built-in text filter input for quickly finding streams. * Add pull request reference to issue 18694 * Use paginated streams endpoint in 'Test against stream' popover Replace the prop-based stream list (fetched all at once) with a paginated fetch using the existing useStreams hook against /streams/paginated. The SelectPopover is replaced with an OverlayTrigger-based popover that includes a debounced search input, a paginated ListGroup of streams, and PaginatedList navigation controls (page size 10). Removes the allStreams prop from MessageActions, MessageDetail, MessageTableEntry, and ShowMessagePage since streams are now fetched directly inside TestAgainstStreamButton. * Remove useCallback wrapper — React Compiler handles memoization * Fix duplicate id on stream filter input; use findBy in tests * Fix TS error: move title to span inside disabled ListGroupItem * Display stream names as actual links. * Fixing linter hints --------- Co-authored-by: Linus Pahl <linus.pahl@graylog.com>
1 parent 39dc87c commit 3e9c27f

6 files changed

Lines changed: 119 additions & 44 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
type = "f"
2+
message = "Sort streams alphabetically in the \"Test against stream\" dropdown and make the dropdown searchable."
3+
4+
issues = ["18694"]
5+
pulls = ["25849"]

graylog2-web-interface/src/components/common/message/details/MessageActions.test.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,27 @@
1515
* <http://www.mongodb.com/licensing/server-side-public-license>.
1616
*/
1717
import * as React from 'react';
18-
import { render } from 'wrappedTestingLibrary';
19-
import * as Immutable from 'immutable';
18+
import userEvent from '@testing-library/user-event';
19+
import { render, screen } from 'wrappedTestingLibrary';
2020

2121
import { asMock } from 'helpers/mocking';
2222
import useSearchConfiguration from 'hooks/useSearchConfiguration';
2323
import mockSearchesClusterConfig from 'fixtures/searchClusterConfig';
24+
import useStreams from 'components/streams/hooks/useStreams';
2425

2526
import MessageActions from './MessageActions';
2627

2728
jest.mock('hooks/useSearchConfiguration', () => jest.fn());
29+
jest.mock('routing/useHistory', () => () => ({ push: jest.fn() }));
30+
jest.mock('components/streams/hooks/useStreams', () => jest.fn());
31+
32+
const mockUseStreams = (streams = [], total = 0) => {
33+
asMock(useStreams).mockReturnValue({
34+
data: { list: streams, pagination: { total }, attributes: [] },
35+
refetch: jest.fn(),
36+
isInitialLoading: false,
37+
});
38+
};
2839

2940
describe('MessageActions', () => {
3041
beforeEach(() => {
@@ -35,6 +46,8 @@ describe('MessageActions', () => {
3546
refresh: jest.fn(),
3647
isInitialLoading: false,
3748
});
49+
50+
mockUseStreams();
3851
});
3952

4053
const renderActions = (props = {}) =>
@@ -51,7 +64,6 @@ describe('MessageActions', () => {
5164
decorationStats={{}}
5265
showOriginal
5366
toggleShowOriginal={() => {}}
54-
streams={Immutable.List()}
5567
{...props}
5668
/>,
5769
);
@@ -67,4 +79,30 @@ describe('MessageActions', () => {
6779

6880
expect(queryByText('Show surrounding messages')).toBeNull();
6981
});
82+
83+
it('renders streams in the "Test against stream" popover', async () => {
84+
const streams = [
85+
{ id: '1', title: 'Zebra Stream', is_default: false },
86+
{ id: '2', title: 'alpha Stream', is_default: false },
87+
{ id: '3', title: 'Mango Stream', is_default: false },
88+
];
89+
90+
mockUseStreams(streams, streams.length);
91+
renderActions();
92+
await userEvent.click(screen.getByText('Test against stream'));
93+
94+
await screen.findByText('Zebra Stream');
95+
await screen.findByText('alpha Stream');
96+
await screen.findByText('Mango Stream');
97+
});
98+
99+
it('renders default stream as disabled', async () => {
100+
const streams = [{ id: '1', title: 'Default Stream', is_default: true }];
101+
102+
mockUseStreams(streams, streams.length);
103+
renderActions();
104+
await userEvent.click(screen.getByText('Test against stream'));
105+
106+
await screen.findByTitle('Cannot test against the default stream');
107+
});
70108
});

graylog2-web-interface/src/components/common/message/details/MessageActions.tsx

Lines changed: 73 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,58 +15,99 @@
1515
* <http://www.mongodb.com/licensing/server-side-public-license>.
1616
*/
1717
import * as React from 'react';
18-
import type * as Immutable from 'immutable';
18+
import { useState } from 'react';
19+
import debounce from 'lodash/debounce';
1920

20-
import { LinkContainer, ClipboardButton, JSONClipboardButton } from 'components/common';
21+
import { ClipboardButton, JSONClipboardButton, Link } from 'components/common';
22+
import OverlayTrigger from 'components/common/OverlayTrigger';
23+
import PaginatedList from 'components/common/PaginatedList';
24+
import Spinner from 'components/common/Spinner';
2125
import Routes from 'routing/Routes';
22-
import { Button, ButtonGroup, DropdownButton, MenuItem } from 'components/bootstrap';
26+
import { Button, ButtonGroup, Input, ListGroup, ListGroupItem } from 'components/bootstrap';
2327
import SurroundingSearchButton from 'components/search/SurroundingSearchButton';
2428
import usePluginEntities from 'hooks/usePluginEntities';
2529
import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants';
2630
import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
2731
import MessagePermalinkButton from 'views/components/common/MessagePermalinkButton';
2832
import useFeature from 'hooks/useFeature';
33+
import useStreams from 'components/streams/hooks/useStreams';
2934

3035
import MessageEditFieldConfigurationAction from './fields/MessageEditFieldConfigurationAction';
3136

32-
const TestAgainstStreamButton = ({
33-
streams,
34-
index,
35-
id,
36-
}: {
37-
streams: Immutable.List<any>;
38-
index: string;
39-
id: string;
40-
}) => {
37+
const PAGE_SIZE = 10;
38+
39+
const TestAgainstStreamButton = ({ index, id }: { index: string; id: string }) => {
4140
const sendTelemetry = useSendTelemetry();
41+
const [searchParams, setSearchParams] = useState({
42+
query: '',
43+
page: 1,
44+
pageSize: PAGE_SIZE,
45+
sort: { attributeId: 'title', direction: 'asc' as const },
46+
});
4247

43-
const sendEvent = () => {
48+
const {
49+
data: { list: streams, pagination },
50+
isInitialLoading,
51+
} = useStreams(searchParams);
52+
53+
const sendEvent = () =>
4454
sendTelemetry(TELEMETRY_EVENT_TYPE.SEARCH_MESSAGE_TABLE_TEST_AGAINST_STREAM, {
4555
app_section: 'search-message-table',
4656
app_action_value: 'seach-message-table-test-against-stream',
4757
});
58+
59+
const handleSearch = debounce((value: string) => {
60+
setSearchParams((cur) => ({ ...cur, query: value, page: 1 }));
61+
}, 300);
62+
63+
const handlePageChange = (newPage: number) => {
64+
setSearchParams((cur) => ({ ...cur, page: newPage }));
4865
};
4966

50-
const streamList = streams.map((stream) => {
51-
if (stream.is_default) {
52-
return (
53-
<MenuItem key={stream.id} onClick={() => sendEvent()} disabled title="Cannot test against the default stream">
54-
{stream.title}
55-
</MenuItem>
56-
);
57-
}
58-
59-
return (
60-
<LinkContainer key={stream.id} to={Routes.stream_edit_example(stream.id, index, id)}>
61-
<MenuItem onClick={() => sendEvent()}>{stream.title}</MenuItem>
62-
</LinkContainer>
63-
);
64-
});
67+
const popoverContent = (
68+
<>
69+
<Input
70+
type="text"
71+
formGroupClassName=""
72+
placeholder="Filter streams"
73+
onChange={({ target: { value } }) => handleSearch(value)}
74+
/>
75+
{isInitialLoading && <Spinner />}
76+
<PaginatedList
77+
showPageSizeSelect={false}
78+
totalItems={pagination.total}
79+
hidePreviousAndNextPageLinks
80+
hideFirstAndLastPageLinks
81+
activePage={searchParams.page}
82+
pageSize={PAGE_SIZE}
83+
onChange={handlePageChange}
84+
useQueryParameter={false}>
85+
<ListGroup>
86+
{streams.map((stream) =>
87+
stream.is_default ? (
88+
<ListGroupItem key={stream.id} disabled>
89+
<span title="Cannot test against the default stream">{stream.title}</span>
90+
</ListGroupItem>
91+
) : (
92+
<ListGroupItem key={stream.id}>
93+
<Link to={Routes.stream_edit_example(stream.id, index, id)} onClick={sendEvent}>
94+
{stream.title}
95+
</Link>
96+
</ListGroupItem>
97+
),
98+
)}
99+
{!isInitialLoading && streams.length === 0 && <ListGroupItem>No streams available</ListGroupItem>}
100+
</ListGroup>
101+
</PaginatedList>
102+
</>
103+
);
65104

66105
return (
67-
<DropdownButton pullRight bsSize="small" title="Test against stream" id="select-stream-dropdown">
68-
{streamList && !streamList.isEmpty() ? streamList.toArray() : <MenuItem header>No streams available</MenuItem>}
69-
</DropdownButton>
106+
<OverlayTrigger trigger="click" placement="bottom" overlay={popoverContent} title="Test against stream" rootClose>
107+
<Button bsSize="small">
108+
Test against stream <span className="caret" />
109+
</Button>
110+
</OverlayTrigger>
70111
);
71112
};
72113

@@ -90,7 +131,6 @@ type Props = {
90131
disableTestAgainstStream: boolean;
91132
showOriginal: boolean;
92133
toggleShowOriginal: () => void;
93-
streams: Immutable.List<any>;
94134
};
95135

96136
const MessageActions = ({
@@ -103,7 +143,6 @@ const MessageActions = ({
103143
disableTestAgainstStream,
104144
showOriginal,
105145
toggleShowOriginal,
106-
streams,
107146
}: Props) => {
108147
const pluggableActions = usePluggableMessageActions(id, index);
109148
const isFavoriteFieldsEnabled = useFeature('message_table_favorite_fields');
@@ -133,7 +172,7 @@ const MessageActions = ({
133172
<ClipboardButton title="Copy ID" text={id} bsSize="small" />
134173
<JSONClipboardButton title="Copy message" bsSize="small" content={fields} />
135174
{surroundingSearchButton}
136-
{disableTestAgainstStream ? null : <TestAgainstStreamButton streams={streams} id={id} index={index} />}
175+
{disableTestAgainstStream ? null : <TestAgainstStreamButton id={id} index={index} />}
137176
{isFavoriteFieldsEnabled && <MessageEditFieldConfigurationAction />}
138177
</ButtonGroup>
139178
);

graylog2-web-interface/src/components/common/message/details/MessageDetail.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ const Header = styled.div`
6464
`;
6565

6666
type Props = {
67-
allStreams?: Immutable.List<Stream>;
6867
disableMessageActions?: boolean;
6968
disableSurroundingSearch?: boolean;
7069
disableTestAgainstStream?: boolean;
@@ -86,7 +85,6 @@ const MessageDetail = ({
8685
streams = Immutable.Map(),
8786
inputs = Immutable.Map(),
8887
showTimestamp = true,
89-
allStreams = Immutable.List(),
9088
}: Props) => {
9189
const isFavoriteFieldsEnabled = useFeature('message_table_favorite_fields');
9290
const [showOriginal, setShowOriginal] = useState(false);
@@ -156,7 +154,6 @@ const MessageDetail = ({
156154
disableTestAgainstStream={disableTestAgainstStream}
157155
showOriginal={showOriginal}
158156
toggleShowOriginal={_toggleShowOriginal}
159-
streams={allStreams}
160157
/>
161158
</Header>
162159
</Col>

graylog2-web-interface/src/components/common/message/messagetable/MessageTableEntry.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ const MessageTableEntry = ({
179179

180180
const sendTelemetry = useSendTelemetry();
181181
const additionalContextValue = useMemo(() => ({ message }), [message]);
182-
const allStreams = useMemo(() => Immutable.List<Stream>(streamsList), [streamsList]);
183182
const streams = useMemo(
184183
() => Immutable.Map<string, Stream>(streamsList.map((stream) => [stream.id, stream])),
185184
[streamsList],
@@ -269,7 +268,6 @@ const MessageTableEntry = ({
269268
message={message}
270269
fields={fields}
271270
streams={streams}
272-
allStreams={allStreams}
273271
inputs={inputs}
274272
disableSurroundingSearch={disableSurroundingSearch}
275273
expandAllRenderAsync={expandAllRenderAsync}

graylog2-web-interface/src/pages/ShowMessagePage.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ const ShowMessagePage = ({ message, messageId, index }: ShowMessagePageProps) =>
8686
() => Immutable.Map(Object.fromEntries(streams.map((stream) => [stream.id, stream]))),
8787
[streams],
8888
);
89-
const streamsList = useMemo(() => Immutable.List(streams), [streams]);
9089
const inputs = useInputs(message?.source_input_id, message?.fields.gl2_source_node);
9190

9291
useEffect(() => {
@@ -115,7 +114,6 @@ const ShowMessagePage = ({ message, messageId, index }: ShowMessagePageProps) =>
115114
<MessageDetail
116115
fields={all}
117116
streams={streamsMap}
118-
allStreams={streamsList}
119117
disableSurroundingSearch
120118
inputs={inputs}
121119
message={message}

0 commit comments

Comments
 (0)