diff --git a/packages/suite-base/package.json b/packages/suite-base/package.json index 3107878ca7..b2a34e114f 100644 --- a/packages/suite-base/package.json +++ b/packages/suite-base/package.json @@ -66,6 +66,7 @@ "@tanstack/react-table": "8.11.7", "@testing-library/jest-dom": "6.6.2", "@testing-library/react": "16.0.0", + "@testing-library/user-event": "14.5.2", "@types/base16": "^1.0.5", "@types/cytoscape": "^3.19.16", "@types/geojson": "7946.0.11", diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx new file mode 100644 index 0000000000..14df474d54 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx @@ -0,0 +1,133 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; +import { Immutable } from "@lichtblick/suite"; +import ExtensionList from "@lichtblick/suite-base/components/ExtensionsSettings/components/ExtensionList/ExtensionList"; +import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; + +import { displayNameForNamespace, generatePlaceholderList } from "./ExtensionList"; + +describe("ExtensionList utility functions", () => { + describe("displayNameForNamespace", () => { + it("returns 'Organization' for 'org'", () => { + expect(displayNameForNamespace("org")).toBe("Organization"); + }); + + it("returns the namespace itself for other values", () => { + const customNamespace = BasicBuilder.string(); + expect(displayNameForNamespace(customNamespace)).toBe(customNamespace); + }); + }); + + describe("generatePlaceholderList", () => { + it("renders a placeholder list with the given message", () => { + const message = BasicBuilder.string(); + render(generatePlaceholderList(message)); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("renders an empty list item when no message is provided", () => { + render(generatePlaceholderList()); + expect(screen.getByRole("listitem")).toBeInTheDocument(); + }); + }); +}); + +describe("ExtensionList Component", () => { + const mockNamespace = "org"; + const mockEntries = [ + { + id: "1", + name: "Extension", + description: "Description of Extension 1", + publisher: "Publisher 1", + version: "1.0.0", + qualifiedName: "org.extension1", + homepage: BasicBuilder.string(), + license: BasicBuilder.string(), + }, + { + id: "2", + name: "Extension2", + description: "Description of Extension 2", + publisher: "Publisher 2", + version: "1.0.0", + qualifiedName: "org.extension2", + homepage: BasicBuilder.string(), + license: BasicBuilder.string(), + }, + ]; + const mockFilterText = "Extension"; + const mockSelectExtension = jest.fn(); + + const emptyMockEntries: Immutable[] = []; + + it("renders the list of extensions correctly", () => { + render( + , + ); + //Since namespace passed was 'org' displayNameForNamespace() transformed it to 'Organization' + expect(screen.getByText("Organization")).toBeInTheDocument(); + + //finds 2 elements that represent the entries from mockEntries + const elements = screen.getAllByText("Extension"); + expect(elements.length).toEqual(2); + }); + + it("renders 'No extensions found' message when entries are empty and there's filterText", () => { + const randomSearchValue = BasicBuilder.string(); + render( + , + ); + + expect(screen.getByText("No extensions found")).toBeInTheDocument(); + }); + + it("renders 'No extensions available' message when entries are empty", () => { + render( + , + ); + + expect(screen.getByText("No extensions available")).toBeInTheDocument(); + }); + + it("calls selectExtension with the correct parameters when an entry is clicked", () => { + render( + , + ); + + const firstEntry = screen.getByText("Extension"); + firstEntry.click(); + + expect(mockSelectExtension).toHaveBeenCalledWith({ + installed: true, + entry: mockEntries[0], + }); + }); +}); diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensionList.tsx b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensionList.tsx new file mode 100644 index 0000000000..6f064e25a1 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensionList.tsx @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { List, ListItem, ListItemText, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { Immutable } from "@lichtblick/suite"; +import { FocusedExtension } from "@lichtblick/suite-base/components/ExtensionsSettings/types"; +import Stack from "@lichtblick/suite-base/components/Stack"; +import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; + +import ExtensionListEntry from "../ExtensionListEntry/ExtensionListEntry"; + +export function displayNameForNamespace(namespace: string): string { + if (namespace === "org") { + return "Organization"; + } else { + return namespace; + } +} + +export function generatePlaceholderList(message?: string): React.ReactElement { + return ( + + + + + + ); +} + +type ExtensionListProps = { + namespace: string; + entries: Immutable[]; + filterText: string; + selectExtension: (newFocusedExtension: FocusedExtension) => void; +}; + +export default function ExtensionList({ + namespace, + entries, + filterText, + selectExtension, +}: ExtensionListProps): React.JSX.Element { + const { t } = useTranslation("extensionsSettings"); + + const renderComponent = () => { + if (entries.length === 0 && filterText) { + return generatePlaceholderList(t("noExtensionsFound")); + } else if (entries.length === 0) { + return generatePlaceholderList(t("noExtensionsAvailable")); + } + return ( + <> + {entries.map((entry) => ( + { + selectExtension({ installed: true, entry }); + }} + searchText={filterText} + /> + ))} + + ); + }; + + return ( + + + + {displayNameForNamespace(namespace)} + + + {renderComponent()} + + ); +} diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.style.ts b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.style.ts new file mode 100644 index 0000000000..030e93efaf --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.style.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { makeStyles } from "tss-react/mui"; + +export const useStyles = makeStyles()((theme) => ({ + listItemButton: { + "&:hover": { color: theme.palette.primary.main }, + }, +})); diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx new file mode 100644 index 0000000000..807c4286e0 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx @@ -0,0 +1,71 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 +import { render, screen, fireEvent } from "@testing-library/react"; + +import { Immutable } from "@lichtblick/suite"; +import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; +import "@testing-library/jest-dom"; + +import ExtensionListEntry from "./ExtensionListEntry"; + +describe("ExtensionListEntry Component", () => { + const mockEntry: Immutable = { + id: BasicBuilder.string(), + name: BasicBuilder.string(), + qualifiedName: BasicBuilder.string(), + description: BasicBuilder.string(), + publisher: BasicBuilder.string(), + homepage: BasicBuilder.string(), + license: BasicBuilder.string(), + version: BasicBuilder.string(), + }; + + const mockOnClick = jest.fn(); + + it("renders primary text with name and highlights search text", () => { + render( + , + ); + + const name = screen.getByText(new RegExp(mockEntry.name, "i")); + expect(name).toBeInTheDocument(); + + const highlightedText = screen.getByText(new RegExp(mockEntry.name, "i")); + expect(highlightedText).toBeInTheDocument(); + expect(highlightedText.tagName).toBe("SPAN"); + }); + + it("renders secondary text with description and publisher", () => { + render( + , + ); + + const description = screen.getByText(new RegExp(mockEntry.description, "i")); + expect(description).toBeInTheDocument(); + + const publisher = screen.getByText(new RegExp(mockEntry.publisher, "i")); + expect(publisher).toBeInTheDocument(); + }); + + it("displays version next to name", () => { + render( + , + ); + + // Check for version + const version = screen.getByText(new RegExp(mockEntry.version, "i")); + expect(version).toBeInTheDocument(); + }); + + it("calls onClick when ListItemButton is clicked", () => { + render(); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.tsx b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.tsx new file mode 100644 index 0000000000..ea40a74393 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.tsx @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { ListItem, ListItemButton, ListItemText, Typography } from "@mui/material"; + +import { Immutable } from "@lichtblick/suite"; +import Stack from "@lichtblick/suite-base/components/Stack"; +import TextHighlight from "@lichtblick/suite-base/components/TextHighlight"; +import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; + +import { useStyles } from "./ExtensionListEntry.style"; + +type Props = { + entry: Immutable; + onClick: () => void; + searchText: string; +}; + +export default function ExtensionListEntry({ + entry: { id, description, name, publisher, version }, + searchText, + onClick, +}: Props): React.JSX.Element { + const { classes } = useStyles(); + return ( + + + + + + + + {version} + + + } + secondary={ + + + {description} + + + {publisher} + + + } + /> + + + ); +} diff --git a/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.test.ts b/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.test.ts new file mode 100644 index 0000000000..a165d7c587 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.test.ts @@ -0,0 +1,109 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { renderHook, act } from "@testing-library/react"; + +import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext"; +import { useExtensionMarketplace } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; + +import useExtensionSettings from "./useExtensionSettings"; + +jest.mock("@lichtblick/suite-base/context/ExtensionCatalogContext"); +jest.mock("@lichtblick/suite-base/context/ExtensionMarketplaceContext"); + +describe("useExtensionSettings", () => { + const mockInstalledExtensions = [ + { + id: "1", + displayName: "Extension 1", + description: "Description 1", + publisher: "Publisher 1", + homepage: "http://example.com", + license: "MIT", + version: "1.0.0", + keywords: ["keyword1"], + namespace: "namespace1", + qualifiedName: "qualifiedName1", + }, + ]; + + const mockAvailableExtensions = [ + { + id: "2", + name: "Extension 2", + description: "Description 2", + publisher: "Publisher 2", + homepage: "http://example.com", + license: "MIT", + version: "1.0.0", + keywords: ["keyword2"], + namespace: "namespace2", + }, + ]; + + const setupHook = async () => { + const renderHookReturn = renderHook(() => useExtensionSettings()); + + // Needed to trigger useEffect + await act(async () => { + await renderHookReturn.result.current.refreshMarketplaceEntries(); + }); + + return renderHookReturn; + }; + + beforeEach(() => { + (useExtensionCatalog as jest.Mock).mockReturnValue(mockInstalledExtensions); + + (useExtensionMarketplace as jest.Mock).mockReturnValue({ + getAvailableExtensions: jest.fn().mockResolvedValue(mockAvailableExtensions), + }); + }); + + it("should initialize correctly", async () => { + const { result } = await setupHook(); + + expect(result.current.undebouncedFilterText).toBe(""); + expect(result.current.debouncedFilterText).toBe(""); + }); + + it("should update filter text", async () => { + const { result } = await setupHook(); + + act(() => { + result.current.setUndebouncedFilterText("test"); + }); + + expect(result.current.undebouncedFilterText).toBe("test"); + }); + + it("should group marketplace entries by namespace", async () => { + const { result } = await setupHook(); + + expect(result.current.groupedMarketplaceData).toEqual([ + { + namespace: "namespace2", + entries: [mockAvailableExtensions[0]], + }, + ]); + }); + + it("should group installed entries by namespace", async () => { + const { result } = await setupHook(); + + expect(result.current.namespacedData).toEqual([ + { + namespace: "namespace1", + entries: [ + { + ...mockInstalledExtensions[0], + installed: true, + name: mockInstalledExtensions[0]!.displayName, + }, + ], + }, + ]); + }); +}); diff --git a/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts b/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts new file mode 100644 index 0000000000..d9b41ce781 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import * as _ from "lodash-es"; +import { useEffect, useMemo, useState } from "react"; +import { useAsyncFn } from "react-use"; +import { useDebounce } from "use-debounce"; + +import Log from "@lichtblick/log"; +import { UseExtensionSettingsHook } from "@lichtblick/suite-base/components/ExtensionsSettings/types"; +import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext"; +import { useExtensionMarketplace } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; + +const log = Log.getLogger(__filename); + +const useExtensionSettings = (): UseExtensionSettingsHook => { + const [undebouncedFilterText, setUndebouncedFilterText] = useState(""); + const [debouncedFilterText] = useDebounce(undebouncedFilterText, 50); + + const installed = useExtensionCatalog((state) => state.installedExtensions); + const marketplace = useExtensionMarketplace(); + + const [marketplaceEntries, refreshMarketplaceEntries] = useAsyncFn( + async () => await marketplace.getAvailableExtensions(), + [marketplace], + ); + + const marketplaceMap = useMemo( + () => _.keyBy(marketplaceEntries.value ?? [], (entry) => entry.id), + [marketplaceEntries], + ); + + const groupedMarketplaceEntries = useMemo(() => { + const entries = marketplaceEntries.value ?? []; + return _.groupBy(entries, (entry) => entry.namespace ?? "default"); + }, [marketplaceEntries]); + + const groupedMarketplaceData = useMemo(() => { + return Object.entries(groupedMarketplaceEntries).map(([namespace, entries]) => ({ + namespace, + entries: entries.filter((entry) => + entry.name.toLowerCase().includes(debouncedFilterText.toLowerCase()), + ), + })); + }, [groupedMarketplaceEntries, debouncedFilterText]); + + const installedEntries = useMemo(() => { + return (installed ?? []).map((entry) => { + const marketplaceEntry = marketplaceMap[entry.id]; + if (marketplaceEntry != undefined) { + return { ...marketplaceEntry, namespace: entry.namespace }; + } + + return { + id: entry.id, + installed: true, + name: entry.displayName, + displayName: entry.displayName, + description: entry.description, + publisher: entry.publisher, + homepage: entry.homepage, + license: entry.license, + version: entry.version, + keywords: entry.keywords, + namespace: entry.namespace, + qualifiedName: entry.qualifiedName, + }; + }); + }, [installed, marketplaceMap]); + + const namespacedEntries = useMemo( + () => _.groupBy(installedEntries, (entry) => entry.namespace), + [installedEntries], + ); + + useEffect(() => { + refreshMarketplaceEntries().catch((error: unknown) => { + log.error(error); + }); + }, [refreshMarketplaceEntries]); + + const namespacedData = Object.entries(namespacedEntries).map(([namespace, entries]) => ({ + namespace, + entries: entries.filter((entry) => + entry.name.toLowerCase().includes(debouncedFilterText.toLowerCase()), + ), + })); + + return { + setUndebouncedFilterText, + marketplaceEntries, + refreshMarketplaceEntries, + undebouncedFilterText, + namespacedData, + groupedMarketplaceData, + debouncedFilterText, + }; +}; + +export default useExtensionSettings; diff --git a/packages/suite-base/src/components/ExtensionsSettings/index.style.ts b/packages/suite-base/src/components/ExtensionsSettings/index.style.ts new file mode 100644 index 0000000000..48c790e2f2 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/index.style.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { makeStyles } from "tss-react/mui"; + +export const useStyles = makeStyles()(() => ({ + searchBarDiv: { + position: "sticky", + top: 0, + zIndex: 1, + }, + searchBarPadding: { + paddingBottom: 13, + }, +})); diff --git a/packages/suite-base/src/components/ExtensionsSettings/index.test.tsx b/packages/suite-base/src/components/ExtensionsSettings/index.test.tsx new file mode 100644 index 0000000000..20e4aa5e90 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/index.test.tsx @@ -0,0 +1,136 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useTranslation } from "react-i18next"; +import "@testing-library/jest-dom"; +import { AsyncState } from "react-use/lib/useAsyncFn"; + +import useExtensionSettings from "@lichtblick/suite-base/components/ExtensionsSettings/hooks/useExtensionSettings"; +import { UseExtensionSettingsHook } from "@lichtblick/suite-base/components/ExtensionsSettings/types"; +import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; + +import ExtensionsSettings from "./index"; + +jest.mock("@lichtblick/suite-base/components/ExtensionsSettings/hooks/useExtensionSettings"); +jest.mock("react-i18next"); + +jest.mock("@lichtblick/suite-base/components/ExtensionDetails", () => ({ + ExtensionDetails: ({ extension, onClose }: any) => { + return ( +
+

{extension.name}

+ +
+ ); + }, +})); + +describe("ExtensionsSettings", () => { + const mockSetUndebouncedFilterText = jest.fn(); + const mockRefreshMarketplaceEntries = jest.fn(); + + function setUpHook(props?: Partial) { + (useExtensionSettings as jest.Mock).mockReturnValue({ + setUndebouncedFilterText: mockSetUndebouncedFilterText, + marketplaceEntries: { error: undefined }, + refreshMarketplaceEntries: mockRefreshMarketplaceEntries, + undebouncedFilterText: "", + namespacedData: [ + { + namespace: "org", + entries: [ + { + id: "1", + name: "Extension", + description: "Description of Extension 1", + publisher: "Publisher 1", + version: "1.0.0", + qualifiedName: "org.extension1", + homepage: BasicBuilder.string(), + license: BasicBuilder.string(), + }, + ], + }, + { namespace: "Org2", entries: [] }, + ], + groupedMarketplaceData: [{ namespace: "MarketPlace", entries: [] }], + debouncedFilterText: "", + ...props, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + setUpHook(); + + (useTranslation as jest.Mock).mockReturnValue({ + t: (key: string) => key, + }); + }); + + it("renders the search bar and three extension lists", () => { + render(); + + expect(screen.getByTestId("SearchBarComponent")).toBeInTheDocument(); + + expect(screen.getByText("Organization")).toBeInTheDocument(); + expect(screen.getByText("Org2")).toBeInTheDocument(); + expect(screen.getByText("MarketPlace")).toBeInTheDocument(); + }); + + it("handles search bar input", async () => { + render(); + + const searchInput = screen.getByPlaceholderText("searchExtensions"); + await userEvent.type(searchInput, "test"); + + expect(mockSetUndebouncedFilterText).toHaveBeenCalledWith("t"); + expect(mockSetUndebouncedFilterText).toHaveBeenCalledWith("e"); + expect(mockSetUndebouncedFilterText).toHaveBeenCalledWith("s"); + expect(mockSetUndebouncedFilterText).toHaveBeenCalledWith("t"); + }); + + it("should clear text when onClose() is called", async () => { + setUpHook({ debouncedFilterText: BasicBuilder.string() }); + render(); + + const clearSearchButton = screen.getByTestId("ClearIcon"); + await userEvent.click(clearSearchButton); + + expect(mockSetUndebouncedFilterText).toHaveBeenCalledWith(""); + }); + + it("displays an error alert when marketplaceEntries.error is set", () => { + setUpHook({ + marketplaceEntries: { error: true } as unknown as AsyncState, + }); + + render(); + + expect(screen.getByText("failedToRetrieveMarketplaceExtensions")).toBeInTheDocument(); + + const retryButton = screen.getByText("Retry"); + fireEvent.click(retryButton); + + expect(mockRefreshMarketplaceEntries).toHaveBeenCalledTimes(1); + }); + + it("should render ExtensionDetails component if focusedExtension is defined and close it", async () => { + render(); + const listItem = screen.getByText("Extension"); + + await userEvent.click(listItem); + expect(screen.queryByTestId("mock-extension-details")).toBeInTheDocument(); + + const closeExtensionButton = screen.getByTestId("mockCloseExtension"); + await userEvent.click(closeExtensionButton); + expect(screen.queryByTestId("mock-extension-details")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/suite-base/src/components/ExtensionsSettings/index.tsx b/packages/suite-base/src/components/ExtensionsSettings/index.tsx index 91d69f0091..086899cb3e 100644 --- a/packages/suite-base/src/components/ExtensionsSettings/index.tsx +++ b/packages/suite-base/src/components/ExtensionsSettings/index.tsx @@ -5,157 +5,46 @@ // 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 { - Alert, - AlertTitle, - Button, - List, - ListItem, - ListItemButton, - ListItemText, - Typography, -} from "@mui/material"; -import * as _ from "lodash-es"; -import { useEffect, useMemo, useState } from "react"; -import { useAsyncFn } from "react-use"; -import { makeStyles } from "tss-react/mui"; +import { Alert, AlertTitle, Button } from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; -import Log from "@lichtblick/log"; -import { Immutable } from "@lichtblick/suite"; import { ExtensionDetails } from "@lichtblick/suite-base/components/ExtensionDetails"; +import useExtensionSettings from "@lichtblick/suite-base/components/ExtensionsSettings/hooks/useExtensionSettings"; +import { FocusedExtension } from "@lichtblick/suite-base/components/ExtensionsSettings/types"; +import SearchBar from "@lichtblick/suite-base/components/SearchBar/SearchBar"; import Stack from "@lichtblick/suite-base/components/Stack"; -import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext"; -import { - ExtensionMarketplaceDetail, - useExtensionMarketplace, -} from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; -const log = Log.getLogger(__filename); - -const useStyles = makeStyles()((theme) => ({ - listItemButton: { - "&:hover": { color: theme.palette.primary.main }, - }, -})); - -function displayNameForNamespace(namespace: string): string { - switch (namespace) { - case "org": - return "Organization"; - default: - return namespace; - } -} - -function ExtensionListEntry(props: { - entry: Immutable; - onClick: () => void; -}): React.JSX.Element { - const { - entry: { id, description, name, publisher, version }, - onClick, - } = props; - const { classes } = useStyles(); - return ( - - - - - {name} - - - {version} - - - } - secondary={ - - - {description} - - - {publisher} - - - } - /> - - - ); -} +import ExtensionList from "./components/ExtensionList/ExtensionList"; +import { useStyles } from "./index.style"; export default function ExtensionsSettings(): React.ReactElement { - const [focusedExtension, setFocusedExtension] = useState< - | { - installed: boolean; - entry: Immutable; - } - | undefined - >(undefined); - const installed = useExtensionCatalog((state) => state.installedExtensions); - const marketplace = useExtensionMarketplace(); - - const [marketplaceEntries, refreshMarketplaceEntries] = useAsyncFn( - async () => await marketplace.getAvailableExtensions(), - [marketplace], - ); - - const marketplaceMap = useMemo( - () => _.keyBy(marketplaceEntries.value ?? [], (entry) => entry.id), - [marketplaceEntries], - ); - - const installedEntries = useMemo( - () => - (installed ?? []).map((entry) => { - const marketplaceEntry = marketplaceMap[entry.id]; - if (marketplaceEntry != undefined) { - return { ...marketplaceEntry, namespace: entry.namespace }; - } - - return { - id: entry.id, - installed: true, - name: entry.displayName, - displayName: entry.displayName, - description: entry.description, - publisher: entry.publisher, - homepage: entry.homepage, - license: entry.license, - version: entry.version, - keywords: entry.keywords, - namespace: entry.namespace, - qualifiedName: entry.qualifiedName, - }; - }), - [installed, marketplaceMap], - ); + const { t } = useTranslation("extensionsSettings"); + const { classes } = useStyles(); - const namespacedEntries = useMemo( - () => _.groupBy(installedEntries, (entry) => entry.namespace), - [installedEntries], - ); + const [focusedExtension, setFocusedExtension] = useState(); - // Hide installed extensions from the list of available extensions - const filteredMarketplaceEntries = useMemo( - () => - _.differenceWith( - marketplaceEntries.value ?? [], - installed ?? [], - (a, b) => a.id === b.id && a.namespace === b.namespace, - ), - [marketplaceEntries, installed], + const { + setUndebouncedFilterText, + marketplaceEntries, + refreshMarketplaceEntries, + undebouncedFilterText, + namespacedData, + groupedMarketplaceData, + debouncedFilterText, + } = useExtensionSettings(); + + const onClear = () => { + setUndebouncedFilterText(""); + }; + + const selectFocusedExtension = useCallback( + (newFocusedExtension: FocusedExtension) => { + setFocusedExtension(newFocusedExtension); + }, + [setFocusedExtension], ); - useEffect(() => { - refreshMarketplaceEntries().catch((error: unknown) => { - log.error(error); - }); - }, [refreshMarketplaceEntries]); - if (focusedExtension != undefined) { return ( } > - Failed to retrieve the list of available marketplace extensions - Check your internet connection and try again. + {t("failedToRetrieveMarketplaceExtensions")} + {t("checkInternetConnection")} )} - {!_.isEmpty(namespacedEntries) ? ( - Object.entries(namespacedEntries).map(([namespace, entries]) => ( - - - - {displayNameForNamespace(namespace)} - - - {entries.map((entry) => ( - { - setFocusedExtension({ installed: true, entry }); - }} - /> - ))} - - )) - ) : ( - - - - - - )} - - - - Available - - - {filteredMarketplaceEntries.map((entry) => ( - { - setFocusedExtension({ installed: false, entry }); - }} - /> - ))} - +
+ { + setUndebouncedFilterText(event.target.value); + }} + value={undebouncedFilterText} + showClearIcon={!!debouncedFilterText} + onClear={onClear} + /> +
+ {namespacedData.map(({ namespace, entries }) => ( + + ))} + {groupedMarketplaceData.map(({ namespace, entries }) => ( + + ))} ); } diff --git a/packages/suite-base/src/components/ExtensionsSettings/types.ts b/packages/suite-base/src/components/ExtensionsSettings/types.ts new file mode 100644 index 0000000000..e465e3c512 --- /dev/null +++ b/packages/suite-base/src/components/ExtensionsSettings/types.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { AsyncState } from "react-use/lib/useAsyncFn"; + +import { Immutable } from "@lichtblick/suite"; +import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext"; + +export type InstalledExtension = { + id: string; + installed: boolean; + name: string; + displayName: string; + description: string; + publisher: string; + homepage?: string; + license?: string; + version: string; + keywords?: string[]; + namespace: string; + qualifiedName: string; +}; + +export type FocusedExtension = { + installed: boolean; + entry: Immutable; +}; + +export type EntryGroupedData = { + namespace: string; + entries: Immutable[]; +}; + +export type UseExtensionSettingsHook = { + setUndebouncedFilterText: (newFilterText: string) => void; + marketplaceEntries: AsyncState; + refreshMarketplaceEntries: () => Promise; + undebouncedFilterText: string; + namespacedData: EntryGroupedData[]; + groupedMarketplaceData: EntryGroupedData[]; + debouncedFilterText: string; +}; diff --git a/packages/suite-base/src/components/SearchBar/SearchBar.style.ts b/packages/suite-base/src/components/SearchBar/SearchBar.style.ts new file mode 100644 index 0000000000..f3b85321d5 --- /dev/null +++ b/packages/suite-base/src/components/SearchBar/SearchBar.style.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { makeStyles } from "tss-react/mui"; + +export const useStyles = makeStyles()((theme) => ({ + filterStartAdornment: { + display: "flex", + }, + filterSearchBar: { + top: 0, + zIndex: theme.zIndex.appBar, + padding: theme.spacing(0.5), + position: "sticky", + backgroundColor: theme.palette.background.paper, + }, +})); diff --git a/packages/suite-base/src/components/SearchBar/SearchBar.test.tsx b/packages/suite-base/src/components/SearchBar/SearchBar.test.tsx new file mode 100644 index 0000000000..6969e844c1 --- /dev/null +++ b/packages/suite-base/src/components/SearchBar/SearchBar.test.tsx @@ -0,0 +1,56 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { render, screen, fireEvent } from "@testing-library/react"; + +import SearchBar from "@lichtblick/suite-base/components/SearchBar/SearchBar"; +import "@testing-library/jest-dom"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; + +describe("SearchBar component", () => { + const mockOnChange = jest.fn(); + const mockOnClear = jest.fn(); + + it("renders with default props", () => { + render(); + + const input = screen.getByTestId("SearchBarComponent"); + expect(input).toBeInTheDocument(); + expect(screen.getByTestId("SearchIcon")).toBeInTheDocument(); + }); + + it("renders with clear icon when showClearIcon is true", () => { + render( + , + ); + + const clearIcon = screen.getByTitle("Clear"); + expect(clearIcon).toBeInTheDocument(); + + fireEvent.click(clearIcon); + expect(mockOnClear).toHaveBeenCalledTimes(1); + }); + it("calls onChange handler when input value changes", () => { + render(); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: BasicBuilder.string() } }); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + }); + + it("does not render clear icon when showClearIcon is false", () => { + render( + , + ); + + expect(screen.queryByTitle("Clear")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/suite-base/src/components/SearchBar/SearchBar.tsx b/packages/suite-base/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..f88dd65259 --- /dev/null +++ b/packages/suite-base/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import ClearIcon from "@mui/icons-material/Clear"; +import SearchIcon from "@mui/icons-material/Search"; +import { IconButton, TextField, InputAdornment } from "@mui/material"; +import { TextFieldProps } from "@mui/material/TextField"; +import { PropsWithChildren } from "react"; + +import { useStyles } from "@lichtblick/suite-base/components/SearchBar/SearchBar.style"; + +function SearchBar( + props: PropsWithChildren< + TextFieldProps & { + onClear?: () => void; + showClearIcon?: boolean; + startAdornment?: React.ReactNode; + } + >, +): React.JSX.Element { + const { + id = "search-bar", + variant = "filled", + disabled = false, + value, + onChange, + onClear, + showClearIcon = false, + startAdornment = , + ...rest + } = props; + + const { classes } = useStyles(); + + return ( +
+ + {startAdornment} + + ), + endAdornment: showClearIcon && ( + + + + + + ), + }} + {...rest} + /> +
+ ); +} + +export default SearchBar; diff --git a/packages/suite-base/src/components/TopicList/TopicList.style.ts b/packages/suite-base/src/components/TopicList/TopicList.style.ts new file mode 100644 index 0000000000..937078b042 --- /dev/null +++ b/packages/suite-base/src/components/TopicList/TopicList.style.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { makeStyles } from "tss-react/mui"; + +export const useStyles = makeStyles()((theme) => ({ + root: { + width: "100%", + height: "100%", + overflow: "hidden", + display: "flex", + flexDirection: "column", + containerType: "inline-size", + }, + filterBar: { + top: 0, + zIndex: theme.zIndex.appBar, + padding: theme.spacing(0.5), + position: "sticky", + backgroundColor: theme.palette.background.paper, + }, + skeletonText: { + marginTop: theme.spacing(0.5), + marginBottom: theme.spacing(0.5), + }, +})); diff --git a/packages/suite-base/src/components/TopicList/TopicList.test.tsx b/packages/suite-base/src/components/TopicList/TopicList.test.tsx new file mode 100644 index 0000000000..ff16f7d58d --- /dev/null +++ b/packages/suite-base/src/components/TopicList/TopicList.test.tsx @@ -0,0 +1,51 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; + +import { useMessagePipeline } from "@lichtblick/suite-base/components/MessagePipeline"; +import { PlayerPresence } from "@lichtblick/suite-base/players/types"; + +import { TopicList } from "./TopicList"; + +// Mock dependencies +jest.mock("@lichtblick/suite-base/components/MessagePipeline"); +jest.mock("./useTopicListSearch"); +jest.mock("./useMultiSelection", () => ({ + useMultiSelection: jest.fn().mockReturnValue({ selectedIndexes: [] }), +})); + +// Mock for useMessagePipeline +const mockUseMessagePipeline = (playerPresence: PlayerPresence) => { + (useMessagePipeline as jest.Mock).mockReturnValue(playerPresence); +}; +// Helper to render TopicList with default mocks +const setup = (playerPresence: PlayerPresence) => { + mockUseMessagePipeline(playerPresence); + return render(); +}; + +describe("TopicList Component", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders EmptyState when playerPresence is NOT_PRESENT", () => { + const { getByText } = setup(PlayerPresence.NOT_PRESENT); + expect(getByText("No data source selected")).toBeInTheDocument(); + }); + + it("renders EmptyState when playerPresence is ERROR", () => { + const { getByText } = setup(PlayerPresence.ERROR); + expect(getByText("An error occurred")).toBeInTheDocument(); + }); + + it("renders loading state when playerPresence is INITIALIZING", () => { + const { getByPlaceholderText, getAllByRole } = setup(PlayerPresence.INITIALIZING); + expect(getByPlaceholderText("Waiting for data…")).toBeInTheDocument(); + expect(getAllByRole("listitem")).toHaveLength(16); + }); +}); diff --git a/packages/suite-base/src/components/TopicList/TopicList.tsx b/packages/suite-base/src/components/TopicList/TopicList.tsx index 14619e8364..bea5264d6e 100644 --- a/packages/suite-base/src/components/TopicList/TopicList.tsx +++ b/packages/suite-base/src/components/TopicList/TopicList.tsx @@ -5,27 +5,16 @@ // 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 ClearIcon from "@mui/icons-material/Clear"; import SearchIcon from "@mui/icons-material/Search"; -import { - IconButton, - List, - ListItem, - ListItemText, - PopoverPosition, - Skeleton, - TextField, -} from "@mui/material"; +import { List, ListItem, ListItemText, PopoverPosition, Skeleton } from "@mui/material"; import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLatest } from "react-use"; import AutoSizer from "react-virtualized-auto-sizer"; import { ListChildComponentProps, VariableSizeList } from "react-window"; -import { makeStyles } from "tss-react/mui"; import { useDebounce } from "use-debounce"; import { filterMap } from "@lichtblick/den/collection"; -import { quoteTopicNameIfNeeded } from "@lichtblick/message-path"; import { useDataSourceInfo } from "@lichtblick/suite-base/PanelAPI"; import { DirectTopicStatsUpdater } from "@lichtblick/suite-base/components/DirectTopicStatsUpdater"; import EmptyState from "@lichtblick/suite-base/components/EmptyState"; @@ -34,68 +23,28 @@ import { useMessagePipeline, } from "@lichtblick/suite-base/components/MessagePipeline"; import { DraggedMessagePath } from "@lichtblick/suite-base/components/PanelExtensionAdapter"; +import SearchBar from "@lichtblick/suite-base/components/SearchBar/SearchBar"; import { ContextMenu } from "@lichtblick/suite-base/components/TopicList/ContextMenu"; +import { getDraggedMessagePath } from "@lichtblick/suite-base/components/TopicList/getDraggedMessagePath"; import { PlayerPresence } from "@lichtblick/suite-base/players/types"; import { MessagePathSelectionProvider } from "@lichtblick/suite-base/services/messagePathDragging/MessagePathSelectionProvider"; import { MessagePathRow } from "./MessagePathRow"; +import { useStyles } from "./TopicList.style"; import { TopicRow } from "./TopicRow"; import { useMultiSelection } from "./useMultiSelection"; -import { TopicListItem, useTopicListSearch } from "./useTopicListSearch"; +import { useTopicListSearch } from "./useTopicListSearch"; const selectPlayerPresence = ({ playerState }: MessagePipelineContext) => playerState.presence; -const useStyles = makeStyles()((theme) => ({ - root: { - width: "100%", - height: "100%", - overflow: "hidden", - display: "flex", - flexDirection: "column", - containerType: "inline-size", - }, - filterBar: { - top: 0, - zIndex: theme.zIndex.appBar, - padding: theme.spacing(0.5), - position: "sticky", - backgroundColor: theme.palette.background.paper, - }, - filterStartAdornment: { - display: "flex", - }, - skeletonText: { - marginTop: theme.spacing(0.5), - marginBottom: theme.spacing(0.5), - }, -})); - -function getDraggedMessagePath(treeItem: TopicListItem): DraggedMessagePath { - switch (treeItem.type) { - case "topic": - return { - path: quoteTopicNameIfNeeded(treeItem.item.item.name), - rootSchemaName: treeItem.item.item.schemaName, - isTopic: true, - isLeaf: false, - topicName: treeItem.item.item.name, - }; - case "schema": - return { - path: treeItem.item.item.fullPath, - rootSchemaName: treeItem.item.item.topic.schemaName, - isTopic: false, - isLeaf: treeItem.item.item.suffix.isLeaf, - topicName: treeItem.item.item.topic.name, - }; - } -} - export function TopicList(): React.JSX.Element { const { t } = useTranslation("topicList"); const { classes } = useStyles(); const [undebouncedFilterText, setFilterText] = useState(""); const [debouncedFilterText] = useDebounce(undebouncedFilterText, 50); + const onClear = () => { + setFilterText(""); + }; const playerPresence = useMessagePipeline(selectPlayerPresence); const { topics, datatypes } = useDataSourceInfo(); @@ -208,7 +157,7 @@ export function TopicList(): React.JSX.Element { return ( <>
-
-
- { - setFilterText(event.target.value); - }} - value={undebouncedFilterText} - fullWidth - placeholder={t("searchBarPlaceholder")} - InputProps={{ - inputProps: { "data-testid": "topic-filter" }, - size: "small", - startAdornment: ( - - ), - endAdornment: undebouncedFilterText && ( - { - setFilterText(""); - }} - edge="end" - > - - - ), - }} - /> -
+ { + setFilterText(event.target.value); + }} + value={undebouncedFilterText} + showClearIcon={!!debouncedFilterText} + onClear={onClear} + /> {treeItems.length > 0 ? (
diff --git a/packages/suite-base/src/components/TopicList/getDraggedMessagePath.test.ts b/packages/suite-base/src/components/TopicList/getDraggedMessagePath.test.ts new file mode 100644 index 0000000000..0ccb71252c --- /dev/null +++ b/packages/suite-base/src/components/TopicList/getDraggedMessagePath.test.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { getDraggedMessagePath } from "@lichtblick/suite-base/components/TopicList/getDraggedMessagePath"; +import { TopicListItem } from "@lichtblick/suite-base/components/TopicList/useTopicListSearch"; + +describe("getDraggedMessagePath", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return correct path for topic type", () => { + const treeItem: TopicListItem = { + type: "topic", + item: { + item: { + name: "testTopic", + schemaName: "testSchema", + }, + positions: new Set(), + start: 0, + end: 0, + score: 0, + }, + }; + const result = getDraggedMessagePath(treeItem); + expect(result).toEqual({ + path: "testTopic", + rootSchemaName: "testSchema", + isTopic: true, + isLeaf: false, + topicName: "testTopic", + }); + }); + + it("should return correct path for schema type", () => { + const treeItem: TopicListItem = { + type: "schema", + item: { + item: { + fullPath: "test/full/path", + topic: { + schemaName: "testSchema", + name: "testTopic", + }, + offset: 0, + suffix: { + isLeaf: true, + pathSuffix: "", + type: "", + }, + }, + positions: new Set(), + start: 0, + end: 0, + score: 0, + }, + }; + const result = getDraggedMessagePath(treeItem); + expect(result).toEqual({ + path: "test/full/path", + rootSchemaName: "testSchema", + isTopic: false, + isLeaf: true, + topicName: "testTopic", + }); + }); +}); diff --git a/packages/suite-base/src/components/TopicList/getDraggedMessagePath.ts b/packages/suite-base/src/components/TopicList/getDraggedMessagePath.ts new file mode 100644 index 0000000000..edc0c2c87c --- /dev/null +++ b/packages/suite-base/src/components/TopicList/getDraggedMessagePath.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { quoteTopicNameIfNeeded } from "@lichtblick/message-path"; +import { DraggedMessagePath } from "@lichtblick/suite-base/components/PanelExtensionAdapter"; +import { TopicListItem } from "@lichtblick/suite-base/components/TopicList/useTopicListSearch"; + +export function getDraggedMessagePath(treeItem: TopicListItem): DraggedMessagePath { + switch (treeItem.type) { + case "topic": + return { + path: quoteTopicNameIfNeeded(treeItem.item.item.name), + rootSchemaName: treeItem.item.item.schemaName, + isTopic: true, + isLeaf: false, + topicName: treeItem.item.item.name, + }; + case "schema": + return { + path: treeItem.item.item.fullPath, + rootSchemaName: treeItem.item.item.topic.schemaName, + isTopic: false, + isLeaf: treeItem.item.item.suffix.isLeaf, + topicName: treeItem.item.item.topic.name, + }; + } +} diff --git a/packages/suite-base/src/i18n/en/extensionsSettings.ts b/packages/suite-base/src/i18n/en/extensionsSettings.ts new file mode 100644 index 0000000000..f4fa734bc7 --- /dev/null +++ b/packages/suite-base/src/i18n/en/extensionsSettings.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +export const extensionsSettings = { + noExtensionsFound: "No extensions found", + noExtensionsAvailable: "No extensions available", + failedToRetrieveMarketplaceExtensions: + "Failed to retrieve the list of available marketplace extensions", + checkInternetConnection: "Check your internet connection and try again.", + searchExtensions: "Search extensions...", + available: "Available", +}; diff --git a/packages/suite-base/src/i18n/en/index.ts b/packages/suite-base/src/i18n/en/index.ts index b8063996d2..63bdb0cb9c 100644 --- a/packages/suite-base/src/i18n/en/index.ts +++ b/packages/suite-base/src/i18n/en/index.ts @@ -10,6 +10,7 @@ export * from "./appBar"; export * from "./appSettings"; export * from "./dataSourceInfo"; export * from "./desktopWindow"; +export * from "./extensionsSettings"; export * from "./gauge"; export * from "./general"; export * from "./incompatibleLayoutVersion"; diff --git a/yarn.lock b/yarn.lock index c0996ade29..cb78959841 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3182,6 +3182,7 @@ __metadata: "@tanstack/react-table": 8.11.7 "@testing-library/jest-dom": 6.6.2 "@testing-library/react": 16.0.0 + "@testing-library/user-event": 14.5.2 "@types/base16": ^1.0.5 "@types/cytoscape": ^3.19.16 "@types/geojson": 7946.0.11