Skip to content

Commit 4f1b320

Browse files
feat: add message nav
1 parent 2f77613 commit 4f1b320

6 files changed

Lines changed: 1318 additions & 5 deletions

File tree

‎packages/suite-base/src/components/TopicList/TopicList.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ export function TopicList(): React.JSX.Element {
171171
/>
172172
</header>
173173
<List dense disablePadding>
174-
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((i) => (
175-
<ListItem divider key={i}>
174+
{Array.from({ length: 16 }).map((_, i) => (
175+
<ListItem divider key={`list-skeleton-${i}`}>
176176
<ListItemText
177177
className={classes.skeletonText}
178178
primary={<Skeleton animation={false} width="20%" />}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/** @jest-environment jsdom */
2+
3+
// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
4+
// SPDX-License-Identifier: MPL-2.0
5+
6+
import "@testing-library/jest-dom";
7+
import { fireEvent, render, screen } from "@testing-library/react";
8+
import { FzfResultItem } from "fzf";
9+
10+
import { Topic } from "@lichtblick/suite-base/players/types";
11+
import { BasicBuilder } from "@lichtblick/test-builders";
12+
13+
import { TopicRow } from "./TopicRow";
14+
import { useTopicMessageNavigation } from "./useTopicMessageNavigation";
15+
16+
const mockUseMessagePipeline = jest.fn();
17+
jest.mock("@lichtblick/suite-base/components/MessagePipeline", () => ({
18+
useMessagePipeline: (selector: unknown) => mockUseMessagePipeline(selector),
19+
}));
20+
21+
jest.mock("@lichtblick/suite-base/services/messagePathDragging", () => ({
22+
useMessagePathDrag: jest.fn().mockReturnValue({
23+
connectDragSource: jest.fn(),
24+
connectDragPreview: jest.fn(),
25+
cursor: "default",
26+
isDragging: false,
27+
draggedItemCount: 0,
28+
}),
29+
}));
30+
31+
jest.mock("./useTopicMessageNavigation");
32+
33+
function setup(topicName = `/${BasicBuilder.string()}`) {
34+
const handleNextMessage = jest.fn().mockResolvedValue(undefined);
35+
const handlePreviousMessage = jest.fn().mockResolvedValue(undefined);
36+
37+
mockUseMessagePipeline.mockReturnValue([{ topic: topicName }]);
38+
39+
(useTopicMessageNavigation as jest.Mock).mockReturnValue({
40+
handleNextMessage,
41+
handlePreviousMessage,
42+
isNavigating: false,
43+
canNavigateNext: true,
44+
canNavigatePrevious: true,
45+
});
46+
47+
const topicResult: FzfResultItem<Topic> = {
48+
item: { name: topicName, schemaName: "test/Schema" },
49+
start: 0,
50+
end: topicName.length,
51+
score: 1,
52+
positions: new Set<number>(),
53+
};
54+
55+
render(
56+
<TopicRow
57+
topicResult={topicResult}
58+
style={{}}
59+
selected={true}
60+
onClick={jest.fn()}
61+
onContextMenu={jest.fn()}
62+
/>,
63+
);
64+
65+
return { handleNextMessage, handlePreviousMessage, topicName };
66+
}
67+
68+
describe("TopicRow navigation buttons", () => {
69+
beforeEach(() => {
70+
jest.clearAllMocks();
71+
});
72+
73+
it("renders navigation buttons", () => {
74+
// Given / When
75+
setup();
76+
77+
// Then
78+
expect(screen.getByRole("button", { name: "Previous message" })).toBeInTheDocument();
79+
expect(screen.getByRole("button", { name: "Next message" })).toBeInTheDocument();
80+
});
81+
82+
it("calls handleNextMessage when next button is clicked", () => {
83+
// Given
84+
const { handleNextMessage } = setup();
85+
86+
// When
87+
fireEvent.click(screen.getByRole("button", { name: "Next message" }));
88+
89+
// Then
90+
expect(handleNextMessage).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it("calls handlePreviousMessage when previous button is clicked", () => {
94+
// Given
95+
const { handlePreviousMessage } = setup();
96+
97+
// When
98+
fireEvent.click(screen.getByRole("button", { name: "Previous message" }));
99+
100+
// Then
101+
expect(handlePreviousMessage).toHaveBeenCalledTimes(1);
102+
});
103+
104+
it("passes correct props to useTopicMessageNavigation", () => {
105+
// Given
106+
const topicName = `/${BasicBuilder.string()}`;
107+
108+
// When
109+
setup(topicName);
110+
111+
// Then
112+
expect(useTopicMessageNavigation).toHaveBeenCalledWith({
113+
topicName,
114+
selected: true,
115+
});
116+
});
117+
});

‎packages/suite-base/src/components/TopicList/TopicRow.tsx‎

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,29 @@
55
// License, v2.0. If a copy of the MPL was not distributed with this
66
// file, You can obtain one at http://mozilla.org/MPL/2.0/
77

8-
import { ReOrderDotsVertical16Regular } from "@fluentui/react-icons";
9-
import { Badge, Typography } from "@mui/material";
8+
import {
9+
ChevronLeft16Regular,
10+
ChevronRight16Regular,
11+
ReOrderDotsVertical16Regular,
12+
} from "@fluentui/react-icons";
13+
import { Badge, IconButton, Tooltip, Typography } from "@mui/material";
1014
import { FzfResultItem } from "fzf";
1115
import { useCallback, useMemo } from "react";
1216

1317
import { quoteTopicNameIfNeeded } from "@lichtblick/message-path";
1418
import { HighlightChars } from "@lichtblick/suite-base/components/HighlightChars";
19+
import { useMessagePipeline } from "@lichtblick/suite-base/components/MessagePipeline";
20+
import { MessagePipelineContext } from "@lichtblick/suite-base/components/MessagePipeline/types";
1521
import { DraggedMessagePath } from "@lichtblick/suite-base/components/PanelExtensionAdapter";
1622
import Stack from "@lichtblick/suite-base/components/Stack";
1723
import { Topic } from "@lichtblick/suite-base/players/types";
1824
import { useMessagePathDrag } from "@lichtblick/suite-base/services/messagePathDragging";
1925

2026
import { TopicStatsChip } from "./TopicStatsChip";
2127
import { useTopicListStyles } from "./useTopicListStyles";
28+
import { useTopicMessageNavigation } from "./useTopicMessageNavigation";
29+
30+
const selectSubscriptions = (ctx: MessagePipelineContext) => ctx.subscriptions;
2231

2332
export function TopicRow({
2433
topicResult,
@@ -37,6 +46,39 @@ export function TopicRow({
3746

3847
const topic = topicResult.item;
3948

49+
const subscriptions = useMessagePipeline(selectSubscriptions);
50+
51+
const isTopicSubscribed = useMemo(
52+
() => subscriptions.some((sub) => sub.topic === topic.name),
53+
[subscriptions, topic.name],
54+
);
55+
56+
const {
57+
handleNextMessage,
58+
handlePreviousMessage,
59+
isNavigating,
60+
canNavigateNext,
61+
canNavigatePrevious,
62+
} = useTopicMessageNavigation({
63+
topicName: topic.name,
64+
isTopicSubscribed,
65+
selected,
66+
});
67+
68+
const isNavigationDisabled = useMemo(
69+
() => isNavigating || !selected || !isTopicSubscribed,
70+
[isNavigating, selected, isTopicSubscribed],
71+
);
72+
73+
const isPreviousDisabled = useMemo(
74+
() => isNavigationDisabled || !canNavigatePrevious,
75+
[isNavigationDisabled, canNavigatePrevious],
76+
);
77+
const isNextDisabled = useMemo(
78+
() => isNavigationDisabled || !canNavigateNext,
79+
[isNavigationDisabled, canNavigateNext],
80+
);
81+
4082
const item: DraggedMessagePath = useMemo(
4183
() => ({
4284
path: quoteTopicNameIfNeeded(topic.name),
@@ -47,6 +89,7 @@ export function TopicRow({
4789
}),
4890
[topic.name, topic.schemaName],
4991
);
92+
5093
const { connectDragSource, connectDragPreview, cursor, isDragging, draggedItemCount } =
5194
useMessagePathDrag({
5295
item,
@@ -61,6 +104,26 @@ export function TopicRow({
61104
[connectDragPreview, connectDragSource],
62105
);
63106

107+
const seekToNextMessage = useCallback(
108+
(event: React.MouseEvent) => {
109+
event.stopPropagation();
110+
void handleNextMessage();
111+
},
112+
[handleNextMessage],
113+
);
114+
115+
const seekToPreviousMessage = useCallback(
116+
(event: React.MouseEvent) => {
117+
event.stopPropagation();
118+
void handlePreviousMessage();
119+
},
120+
[handlePreviousMessage],
121+
);
122+
123+
const handleButtonMouseDown = useCallback((event: React.MouseEvent) => {
124+
event.stopPropagation();
125+
}, []);
126+
64127
return (
65128
<div
66129
ref={combinedRef}
@@ -100,7 +163,39 @@ export function TopicRow({
100163
</Typography>
101164
)}
102165
</Stack>
103-
<TopicStatsChip selected={selected} topicName={topic.name} />
166+
<Stack direction="column" alignItems="center" gap={0.5}>
167+
<TopicStatsChip selected={selected} topicName={topic.name} />
168+
<Stack direction="row" gap={0.5} alignItems="center">
169+
<Tooltip title={isPreviousDisabled ? "" : "Previous message"}>
170+
<span>
171+
<IconButton
172+
size="small"
173+
aria-label="Previous message"
174+
onClick={seekToPreviousMessage}
175+
onMouseDown={handleButtonMouseDown}
176+
disabled={isPreviousDisabled}
177+
className={classes.navIconButton}
178+
>
179+
<ChevronLeft16Regular />
180+
</IconButton>
181+
</span>
182+
</Tooltip>
183+
<Tooltip title={isNextDisabled ? "" : "Next message"}>
184+
<span>
185+
<IconButton
186+
size="small"
187+
aria-label="Next message"
188+
onClick={seekToNextMessage}
189+
onMouseDown={handleButtonMouseDown}
190+
disabled={isNextDisabled}
191+
className={classes.navIconButton}
192+
>
193+
<ChevronRight16Regular />
194+
</IconButton>
195+
</span>
196+
</Tooltip>
197+
</Stack>
198+
</Stack>
104199
<div data-testid="TopicListDragHandle" className={classes.dragHandle}>
105200
<ReOrderDotsVertical16Regular />
106201
</div>

‎packages/suite-base/src/components/TopicList/useTopicListStyles.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const useTopicListStyles = makeStyles<void, TreeClasses>()((theme, _, cla
5151
position: "absolute",
5252
inset: "-1px 0 -1px 0",
5353
border: `1px solid ${theme.palette.primary.main}`,
54+
pointerEvents: "none",
5455
},
5556
[`& + .${classes.row}`]: {
5657
borderTop: `1px solid ${theme.palette.primary.main}`,
@@ -93,5 +94,8 @@ export const useTopicListStyles = makeStyles<void, TreeClasses>()((theme, _, cla
9394
display: "block",
9495
textAlign: "start",
9596
},
97+
navIconButton: {
98+
padding: theme.spacing(0.25),
99+
},
96100
/* eslint-enable tss-unused-classes/unused-classes */
97101
}));

0 commit comments

Comments
 (0)