Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
46164e5
Test data with DataGrid OK
gabriela-almeida-ctw Feb 5, 2026
bf3eefa
Add logic to handle install/uninstall extension
gabriela-almeida-ctw Feb 6, 2026
72d5149
Add logic to handle install/uninstall multiple extensions
gabriela-almeida-ctw Feb 9, 2026
9ba1857
Remove bulk install (intentionally)
gabriela-almeida-ctw Feb 11, 2026
d610f06
Adjust column sizes
gabriela-almeida-ctw Feb 11, 2026
300ecc6
Relocate consts types and styles
gabriela-almeida-ctw Feb 12, 2026
2ed77aa
Extract common extensions operations into a hook - install uninstall …
gabriela-almeida-ctw Feb 12, 2026
23e180b
Extract button logic into new components
gabriela-almeida-ctw Feb 12, 2026
f914813
Remove unused component
gabriela-almeida-ctw Feb 13, 2026
82c848f
fix dependency
rodrigo-p-rodrigues Feb 18, 2026
75e101e
rename file
rodrigo-p-rodrigues Feb 18, 2026
89ce953
fix unit tests
rodrigo-p-rodrigues Feb 18, 2026
babfaa3
test push
rodrigo-p-rodrigues Feb 18, 2026
1e1e1c8
remove unnecessary comment
rodrigo-p-rodrigues Feb 18, 2026
db8c415
remove deprecated property
rodrigo-p-rodrigues Feb 18, 2026
3b76b26
add unit tests to handleBulkUninstall
rodrigo-p-rodrigues Feb 18, 2026
de5574e
Make row size fixed for bulk action button
gabriela-almeida-ctw Feb 19, 2026
7f95402
Apply PR suggestion - stable scrollbar gutter
gabriela-almeida-ctw Feb 19, 2026
54a97e4
Apply PR suggestion - extract button actions to a shared component
gabriela-almeida-ctw Feb 19, 2026
661f80a
Apply PR suggestion - remove inline styling to allow columns resize
gabriela-almeida-ctw Feb 20, 2026
b3d33c9
Apply PR suggestion - hide actions from marketplace extensions
gabriela-almeida-ctw Feb 20, 2026
50c894f
Merge remote-tracking branch 'origin/develop' into feature/improve-ex…
gabriela-almeida-ctw Feb 20, 2026
6e00d09
Apply PR suggestion - use ExtensionBuilder
gabriela-almeida-ctw Feb 23, 2026
b7bfd48
Merge remote-tracking branch 'origin/develop' into feature/improve-ex…
gabriela-almeida-ctw Feb 23, 2026
6e02b84
Correct lint
gabriela-almeida-ctw Feb 23, 2026
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
1 change: 1 addition & 0 deletions packages/suite-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@mcap/core": "2.1.6",
"@mui/icons-material": "5.15.5",
"@mui/material": "7.2.0",
"@mui/x-data-grid": "7.24.1",
"@popperjs/core": "^2.11.8",
"@protobufjs/base64": "1.1.2",
"@storybook/jest": "0.2.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function AppSettingsDialog(
/>
<Tab className={classes.tab} label={t("about")} value="about" />
</Tabs>
<Stack direction="row" fullHeight overflowY="auto">
<Stack direction="row" fullHeight overflowY="auto" style={{ scrollbarGutter: "stable" }}>
<section
className={cx(classes.tabPanel, {
[classes.tabPanelActive]: activeTab === "general",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@

import { makeStyles } from "tss-react/mui";

export const useStyles = makeStyles()((theme) => ({
listItemButton: {
"&:hover": { color: theme.palette.primary.main },
export const useStylesExtensionDetails = makeStyles()((theme) => ({
backButton: {
marginLeft: theme.spacing(-1.5),
marginBottom: theme.spacing(1),
},
installButton: {
minWidth: 100,
},
}));
17 changes: 15 additions & 2 deletions packages/suite-base/src/components/ExtensionDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,21 @@ describe("ExtensionDetails Component", () => {
});

it("does not log analytics event when component is unmounted before install completes", async () => {
// Given
(isDesktopApp as jest.Mock).mockReturnValue(true);

mockDownloadExtension.mockResolvedValue(new Uint8Array());
mockInstallExtensions.mockResolvedValue({});
// Add delays to ensure unmount happens during the async operation
mockDownloadExtension.mockImplementation(
async () =>
await new Promise((resolve) =>
setTimeout(() => {
resolve(new Uint8Array());
}, 100),
),
);
mockInstallExtensions.mockImplementation(
async () => await new Promise((resolve) => setTimeout(resolve, 100)),
);

const { unmount } = render(
<ExtensionDetails extension={mockExtension} onClose={() => {}} installed={false} />,
Expand All @@ -252,9 +263,11 @@ describe("ExtensionDetails Component", () => {
const installButton = screen.getByText("Install");
fireEvent.click(installButton);

// When
// Unmount before the async operation completes
unmount();

// Then
await waitFor(() => {
expect(mockDownloadExtension).toHaveBeenCalled();
expect(mockInstallExtensions).toHaveBeenCalled();
Expand Down
204 changes: 45 additions & 159 deletions packages/suite-base/src/components/ExtensionDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,75 +8,59 @@
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { Button, Link, Tab, Tabs, Typography, Divider } from "@mui/material";
import DOMPurify from "dompurify";
import { useSnackbar } from "notistack";
import { useCallback, useState } from "react";
import { useState } from "react";
import { useAsync, useMountedState } from "react-use";
import { makeStyles } from "tss-react/mui";

import { formatByteSize } from "@lichtblick/den/format";
import { Immutable } from "@lichtblick/suite";
import { useStylesExtensionDetails } from "@lichtblick/suite-base/components/ExtensionDetails.style";
import { InstallButton } from "@lichtblick/suite-base/components/ExtensionsSettings/components/ExtensionActionButton/InstallButton";
import { UninstallButton } from "@lichtblick/suite-base/components/ExtensionsSettings/components/ExtensionActionButton/UninstallButton";
import { useExtensionOperations } from "@lichtblick/suite-base/components/ExtensionsSettings/hooks/useExtensionOperations";
import {
ExtensionActionsLabel,
ExtensionOperationStatusLabel,
} from "@lichtblick/suite-base/components/ExtensionsSettings/types";
import Stack from "@lichtblick/suite-base/components/Stack";
import TextContent from "@lichtblick/suite-base/components/TextContent";
import { useAnalytics } from "@lichtblick/suite-base/context/AnalyticsContext";
import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext";
import {
ExtensionMarketplaceDetail,
useExtensionMarketplace,
} from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
import { AppEvent } from "@lichtblick/suite-base/services/IAnalytics";
import isDesktopApp from "@lichtblick/suite-base/util/isDesktopApp";
import { ExtensionDetailsProps, OperationStatus } from "@lichtblick/suite-base/components/types";
import { useExtensionMarketplace } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
import { isValidUrl } from "@lichtblick/suite-base/util/isValidURL";

type Props = {
installed: boolean;
extension: Immutable<ExtensionMarketplaceDetail>;
onClose: () => void;
};

const useStyles = makeStyles()((theme) => ({
backButton: {
marginLeft: theme.spacing(-1.5),
marginBottom: theme.spacing(1),
},
installButton: {
minWidth: 100,
},
}));

enum OperationStatus {
IDLE = "idle",
INSTALLING = "installing",
UNINSTALLING = "uninstalling",
}

/**
* ExtensionDetails component displays detailed information about a specific extension.
* It allows users to install, uninstall, and view the README and CHANGELOG of the extension.
*
* @param {Object} props - The component props.
* @param {boolean} props.installed - Indicates if the extension is already installed.
* @param {Immutable<ExtensionMarketplaceDetail>} props.extension - The extension details.
* @param {ExtensionMarketplaceDetail} props.extension - The extension details.
* @param {Function} props.onClose - Callback function to close the details view.
* @returns {React.ReactElement} The rendered component.
*/
export function ExtensionDetails({
extension,
onClose,
installed,
}: Readonly<Props>): React.ReactElement {
const { classes } = useStyles();
}: Readonly<ExtensionDetailsProps>): React.ReactElement {
const { classes } = useStylesExtensionDetails();
const [isInstalled, setIsInstalled] = useState(installed);
const [operationStatus, setOperationStatus] = useState<OperationStatus>(OperationStatus.IDLE);
const [activeTab, setActiveTab] = useState<number>(0);
const isMounted = useMountedState();
const downloadExtension = useExtensionCatalog((state) => state.downloadExtension);
const installExtensions = useExtensionCatalog((state) => state.installExtensions);
const uninstallExtension = useExtensionCatalog((state) => state.uninstallExtension);
const marketplace = useExtensionMarketplace();
const { enqueueSnackbar } = useSnackbar();
const readme = extension.readme;
const changelog = extension.changelog;
const canInstall = extension.foxe != undefined;

const { handleInstall, handleUninstall, operationStatus } = useExtensionOperations({
onInstallSuccess: () => {
if (isMounted()) {
setIsInstalled(true);
}
},
onUninstallSuccess: () => {
if (isMounted()) {
setIsInstalled(false);
}
},
});

const { value: readmeContent } = useAsync(
async () =>
Expand All @@ -93,98 +77,6 @@ export function ExtensionDetails({
[marketplace, changelog],
);

const analytics = useAnalytics();

/**
* Handles the download and installation of the extension.
*
* @async
* @function downloadAndInstall
* @returns {Promise<void>}
*/
const downloadAndInstall = useCallback(async () => {
if (!isDesktopApp()) {
enqueueSnackbar("Download the desktop app to use marketplace extensions.", {
variant: "error",
});
return;
}

const url = extension.foxe;
if (url == undefined) {
enqueueSnackbar(`Cannot install extension ${extension.id}, "foxe" URL is missing`, {
variant: "error",
});
return;
}

setOperationStatus(OperationStatus.INSTALLING);

try {
const extensionBuffer = await downloadExtension(url);
await installExtensions("local", [{ buffer: extensionBuffer }]);

enqueueSnackbar(`${extension.name} installed successfully`, { variant: "success" });

if (isMounted()) {
setIsInstalled(true);
void analytics.logEvent(AppEvent.EXTENSION_INSTALL, { type: extension.id });
}
} catch (error) {
enqueueSnackbar(error instanceof Error ? error.message : "Failed to install extension", {
variant: "error",
});
} finally {
setOperationStatus(OperationStatus.IDLE);
}
}, [
analytics,
downloadExtension,
enqueueSnackbar,
extension.foxe,
extension.id,
extension.name,
installExtensions,
isMounted,
]);

/**
* Handles the uninstallation of the extension.
*
* @async
* @function uninstall
* @returns {Promise<void>}
*/
const uninstall = useCallback(async () => {
setOperationStatus(OperationStatus.UNINSTALLING);

try {
// UX - Avoids the button from blinking when operation completes too fast
await new Promise((resolve) => setTimeout(resolve, 200));
await uninstallExtension(extension.namespace ?? "local", extension.id);
enqueueSnackbar(`${extension.name} uninstalled successfully`, { variant: "success" });

if (isMounted()) {
setIsInstalled(false);
void analytics.logEvent(AppEvent.EXTENSION_UNINSTALL, { type: extension.id });
}
} catch (error) {
enqueueSnackbar(error instanceof Error ? error.message : "Failed to uninstall extension", {
variant: "error",
});
} finally {
setOperationStatus(OperationStatus.IDLE);
}
}, [
analytics,
enqueueSnackbar,
extension.id,
extension.name,
extension.namespace,
isMounted,
uninstallExtension,
]);

return (
<Stack fullHeight flex="auto" gap={1}>
<div>
Expand Down Expand Up @@ -234,31 +126,25 @@ export function ExtensionDetails({
</Typography>
</Stack>
{isInstalled ? (
<Button
className={classes.installButton}
size="small"
key="uninstall"
color="inherit"
variant="contained"
onClick={uninstall}
disabled={operationStatus !== OperationStatus.IDLE}
>
{operationStatus === OperationStatus.UNINSTALLING ? "Uninstalling..." : "Uninstall"}
</Button>
<UninstallButton
extension={extension}
onAction={handleUninstall}
isOperating={operationStatus !== OperationStatus.IDLE}
operationStatus={operationStatus}
stopPropagation
label={ExtensionActionsLabel.UNINSTALL}
loadingLabel={ExtensionOperationStatusLabel.UNINSTALLING}
/>
) : (
canInstall && (
<Button
className={classes.installButton}
size="small"
key="install"
color="inherit"
variant="contained"
onClick={downloadAndInstall}
disabled={operationStatus !== "idle"}
>
{operationStatus === OperationStatus.INSTALLING ? "Installing..." : "Install"}
</Button>
)
<InstallButton
extension={extension}
onAction={handleInstall}
isOperating={operationStatus !== OperationStatus.IDLE}
operationStatus={operationStatus}
stopPropagation
label={ExtensionActionsLabel.INSTALL}
loadingLabel={ExtensionOperationStatusLabel.INSTALLING}
/>
)}
</Stack>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
// SPDX-License-Identifier: MPL-2.0

import { Button } from "@mui/material";

import { ExtensionActionButtonProps } from "@lichtblick/suite-base/components/ExtensionsSettings/types";
import { OperationStatus } from "@lichtblick/suite-base/components/types";

/**
* Action button component for extensions.
* Only renders if the extension can be installed (has a foxe URL).
*/
export function ExtensionActionButton({
extension,
onAction,
isOperating,
operationStatus,
className,
stopPropagation = false,
color = "primary",
variant = "contained",
label,
loadingLabel,
}: Readonly<ExtensionActionButtonProps>): React.ReactElement | undefined {
return (
<Button
className={className}
size="small"
color={color}
variant={variant}
onClick={async (event) => {
if (stopPropagation) {
event.stopPropagation();
}
await onAction(extension);
}}
disabled={isOperating}
>
{isOperating && operationStatus !== OperationStatus.IDLE ? loadingLabel : label}
</Button>
);
}
Comment thread
gabriela-almeida-ctw marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
// SPDX-License-Identifier: MPL-2.0

import { ExtensionActionButton } from "@lichtblick/suite-base/components/ExtensionsSettings/components/ExtensionActionButton/ExtensionActionButton";
import { ExtensionActionButtonProps } from "@lichtblick/suite-base/components/ExtensionsSettings/types";
import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
import { canInstallExtension } from "@lichtblick/suite-base/util/canInstallExtension";

/**
* Install button component for extensions.
* Only renders if the extension can be installed (has a foxe URL).
*/
export function InstallButton(
props: Readonly<ExtensionActionButtonProps>,
): React.ReactElement | undefined {
if (!canInstallExtension(props.extension as ExtensionMarketplaceDetail)) {
return undefined;
}

return <ExtensionActionButton {...props} />;
}
Loading
Loading