Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
4 changes: 2 additions & 2 deletions packages/suite-base/src/components/TopicList/TopicList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ export function TopicList(): React.JSX.Element {
/>
</header>
<List dense disablePadding>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((i) => (
<ListItem divider key={i}>
{Array.from({ length: 16 }).map((_, i) => (
<ListItem divider key={`list-skeleton-${i}`}>
<ListItemText
className={classes.skeletonText}
primary={<Skeleton animation={false} width="20%" />}
Expand Down
159 changes: 159 additions & 0 deletions packages/suite-base/src/components/TopicList/TopicRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/** @jest-environment jsdom */

// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
// SPDX-License-Identifier: MPL-2.0

import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { FzfResultItem } from "fzf";

import { Topic } from "@lichtblick/suite-base/players/types";
import { BasicBuilder } from "@lichtblick/test-builders";

import { TopicRow } from "./TopicRow";
import { useTopicMessageNavigation } from "./useTopicMessageNavigation";

const mockUseMessagePipeline = jest.fn();
jest.mock("@lichtblick/suite-base/components/MessagePipeline", () => ({
useMessagePipeline: (selector: unknown) => mockUseMessagePipeline(selector),
}));

jest.mock("@lichtblick/suite-base/services/messagePathDragging", () => ({
useMessagePathDrag: jest.fn().mockReturnValue({
connectDragSource: jest.fn(),
connectDragPreview: jest.fn(),
cursor: "default",
isDragging: false,
draggedItemCount: 0,
}),
}));

jest.mock("./useTopicMessageNavigation");

interface SetupOptions {
topicName?: string;
isNavigating?: boolean;
canNavigateNext?: boolean;
canNavigatePrevious?: boolean;
}

function setup(options: SetupOptions = {}) {
const {
topicName = `/${BasicBuilder.string()}`,
isNavigating = false,
canNavigateNext = true,
canNavigatePrevious = true,
} = options;

const handleNextMessage = jest.fn().mockResolvedValue(undefined);
const handlePreviousMessage = jest.fn().mockResolvedValue(undefined);

mockUseMessagePipeline.mockReturnValue([{ topic: topicName }]);

(useTopicMessageNavigation as jest.Mock).mockReturnValue({
handleNextMessage,
handlePreviousMessage,
isNavigating,
canNavigateNext,
canNavigatePrevious,
});

const topicResult: FzfResultItem<Topic> = {
item: { name: topicName, schemaName: "test/Schema" },
start: 0,
end: topicName.length,
score: 1,
positions: new Set<number>(),
};

render(
<TopicRow
topicResult={topicResult}
style={{}}
selected={true}
onClick={jest.fn()}
onContextMenu={jest.fn()}
/>,
);

return { handleNextMessage, handlePreviousMessage, topicName };
}

describe("TopicRow navigation buttons", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("renders navigation buttons", () => {
// Given / When
setup();

// Then
expect(screen.getByRole("button", { name: "Previous message" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next message" })).toBeInTheDocument();
});

it("calls handleNextMessage when next button is clicked", () => {
// Given
const { handleNextMessage } = setup();

// When
fireEvent.click(screen.getByRole("button", { name: "Next message" }));

// Then
expect(handleNextMessage).toHaveBeenCalledTimes(1);
});

it("calls handlePreviousMessage when previous button is clicked", () => {
// Given
const { handlePreviousMessage } = setup();

// When
fireEvent.click(screen.getByRole("button", { name: "Previous message" }));

// Then
expect(handlePreviousMessage).toHaveBeenCalledTimes(1);
});

it("passes correct props to useTopicMessageNavigation", () => {
// Given
const topicName = `/${BasicBuilder.string()}`;

// When
setup({ topicName });

// Then
expect(useTopicMessageNavigation).toHaveBeenCalledWith({
topicName,
selected: true,
isTopicSubscribed: true,
});
});

it("disables both navigation buttons when isNavigating is true", () => {
// Given / When
setup({ isNavigating: true });

// Then
expect(screen.getByRole("button", { name: "Previous message" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next message" })).toBeDisabled();
});

it("disables next button when canNavigateNext is false", () => {
// Given / When
setup({ canNavigateNext: false });

// Then
expect(screen.getByRole("button", { name: "Previous message" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Next message" })).toBeDisabled();
});

it("disables previous button when canNavigatePrevious is false", () => {
// Given / When
setup({ canNavigatePrevious: false });

// Then
expect(screen.getByRole("button", { name: "Previous message" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next message" })).toBeEnabled();
});
});
Comment thread
andremarques94 marked this conversation as resolved.
Resolved
101 changes: 98 additions & 3 deletions packages/suite-base/src/components/TopicList/TopicRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@
// License, v2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import { ReOrderDotsVertical16Regular } from "@fluentui/react-icons";
import { Badge, Typography } from "@mui/material";
import {
ChevronLeft16Regular,
ChevronRight16Regular,
ReOrderDotsVertical16Regular,
} from "@fluentui/react-icons";
import { Badge, IconButton, Tooltip, Typography } from "@mui/material";
import { FzfResultItem } from "fzf";
import { useCallback, useMemo } from "react";

import { quoteTopicNameIfNeeded } from "@lichtblick/message-path";
import { HighlightChars } from "@lichtblick/suite-base/components/HighlightChars";
import { useMessagePipeline } from "@lichtblick/suite-base/components/MessagePipeline";
import { MessagePipelineContext } from "@lichtblick/suite-base/components/MessagePipeline/types";
import { DraggedMessagePath } from "@lichtblick/suite-base/components/PanelExtensionAdapter";
import Stack from "@lichtblick/suite-base/components/Stack";
import { Topic } from "@lichtblick/suite-base/players/types";
import { useMessagePathDrag } from "@lichtblick/suite-base/services/messagePathDragging";

import { TopicStatsChip } from "./TopicStatsChip";
import { useTopicListStyles } from "./useTopicListStyles";
import { useTopicMessageNavigation } from "./useTopicMessageNavigation";

const selectSubscriptions = (ctx: MessagePipelineContext) => ctx.subscriptions;

export function TopicRow({
topicResult,
Expand All @@ -37,6 +46,39 @@ export function TopicRow({

const topic = topicResult.item;

const subscriptions = useMessagePipeline(selectSubscriptions);

const isTopicSubscribed = useMemo(
() => subscriptions.some((sub) => sub.topic === topic.name),
[subscriptions, topic.name],
);

const {
handleNextMessage,
handlePreviousMessage,
isNavigating,
canNavigateNext,
canNavigatePrevious,
} = useTopicMessageNavigation({
topicName: topic.name,
isTopicSubscribed,
selected,
});

const isNavigationDisabled = useMemo(
() => isNavigating || !selected || !isTopicSubscribed,
[isNavigating, selected, isTopicSubscribed],
);

const isPreviousDisabled = useMemo(
() => isNavigationDisabled || !canNavigatePrevious,
[isNavigationDisabled, canNavigatePrevious],
);
const isNextDisabled = useMemo(
() => isNavigationDisabled || !canNavigateNext,
[isNavigationDisabled, canNavigateNext],
);

const item: DraggedMessagePath = useMemo(
() => ({
path: quoteTopicNameIfNeeded(topic.name),
Expand All @@ -47,6 +89,7 @@ export function TopicRow({
}),
[topic.name, topic.schemaName],
);

const { connectDragSource, connectDragPreview, cursor, isDragging, draggedItemCount } =
useMessagePathDrag({
item,
Expand All @@ -61,6 +104,26 @@ export function TopicRow({
[connectDragPreview, connectDragSource],
);

const seekToNextMessage = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
void handleNextMessage();
},
[handleNextMessage],
);

const seekToPreviousMessage = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
void handlePreviousMessage();
},
[handlePreviousMessage],
);

const handleButtonMouseDown = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);

return (
<div
ref={combinedRef}
Expand Down Expand Up @@ -100,7 +163,39 @@ export function TopicRow({
</Typography>
)}
</Stack>
<TopicStatsChip selected={selected} topicName={topic.name} />
<Stack direction="column" alignItems="center" gap={0.5}>
Comment thread
andremarques94 marked this conversation as resolved.
Resolved
<TopicStatsChip selected={selected} topicName={topic.name} />
<Stack direction="row" gap={0.5} alignItems="center">
<Tooltip title={isPreviousDisabled ? "" : "Previous message"}>
<span>
<IconButton
size="small"
aria-label="Previous message"
onClick={seekToPreviousMessage}
onMouseDown={handleButtonMouseDown}
disabled={isPreviousDisabled}
className={classes.navIconButton}
>
<ChevronLeft16Regular />
</IconButton>
</span>
</Tooltip>
<Tooltip title={isNextDisabled ? "" : "Next message"}>
<span>
<IconButton
size="small"
aria-label="Next message"
onClick={seekToNextMessage}
onMouseDown={handleButtonMouseDown}
disabled={isNextDisabled}
className={classes.navIconButton}
>
<ChevronRight16Regular />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
<div data-testid="TopicListDragHandle" className={classes.dragHandle}>
<ReOrderDotsVertical16Regular />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const useTopicListStyles = makeStyles<void, TreeClasses>()((theme, _, cla
position: "absolute",
inset: "-1px 0 -1px 0",
border: `1px solid ${theme.palette.primary.main}`,
pointerEvents: "none",
},
[`& + .${classes.row}`]: {
borderTop: `1px solid ${theme.palette.primary.main}`,
Expand Down Expand Up @@ -93,5 +94,8 @@ export const useTopicListStyles = makeStyles<void, TreeClasses>()((theme, _, cla
display: "block",
textAlign: "start",
},
navIconButton: {
padding: theme.spacing(0.25),
},
/* eslint-enable tss-unused-classes/unused-classes */
}));
Loading