Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
123 changes: 123 additions & 0 deletions packages/suite-base/src/components/LayoutBrowser/LayoutRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,126 @@ describe("LayoutRow publish to catalog", () => {
expect(screen.getByTestId("publish-layout-to-catalog")).toBeDisabled();
});
});

describe("LayoutRow refetch from server", () => {
// LayoutBuilder fills unset props with defaults, so `working` has to be
// cleared afterwards to get a layout with no unsaved changes.
const unmodifiedOrgLayout: Layout = {
...LayoutBuilder.layout({ permission: "ORG_WRITE" }),
working: undefined,
};

beforeEach(() => {
jest.clearAllMocks();
mockLayoutManager.isOnline = true;
(LayoutManagerContext.useLayoutManager as jest.Mock).mockReturnValue(mockLayoutManager);
(useConfirmModule.useConfirm as jest.Mock).mockReturnValue([mockConfirm, mockConfirmModal]);
});

it("Given an unmodified org layout, when refetch is clicked, then it refetches without confirming", async () => {
const onRefetch = jest.fn();

renderComponent({ layout: unmodifiedOrgLayout, onRefetch });

fireEvent.click(screen.getByTestId("layout-actions"));
fireEvent.click(screen.getByTestId("refetch-layout"));

await waitFor(() => {
expect(onRefetch).toHaveBeenCalledWith(unmodifiedOrgLayout);
});
expect(mockConfirm).not.toHaveBeenCalled();
});

it("Given a read-only catalog layout, when menu is opened, then refetch is still offered", () => {
const catalogLayout = LayoutBuilder.layout({ permission: "ORG_READ" });

renderComponent(
{ layout: catalogLayout, onRefetch: jest.fn() },
{ orgLayoutCapabilities: { canPublishCatalog: false } },
);

fireEvent.click(screen.getByTestId("layout-actions"));

expect(screen.getByTestId("refetch-layout")).toBeInTheDocument();
});

it("Given a personal layout, when menu is opened, then refetch is hidden", () => {
const personalLayout = LayoutBuilder.layout({ permission: "CREATOR_WRITE" });

renderComponent({ layout: personalLayout, onRefetch: jest.fn() });

fireEvent.click(screen.getByTestId("layout-actions"));

expect(screen.queryByTestId("refetch-layout")).not.toBeInTheDocument();
});

it("Given no refetch handler, when menu is opened on an org layout, then refetch is hidden", () => {
renderComponent({ layout: unmodifiedOrgLayout });

fireEvent.click(screen.getByTestId("layout-actions"));

expect(screen.queryByTestId("refetch-layout")).not.toBeInTheDocument();
});

it("Given offline, when menu is opened on an org layout, then refetch is disabled", () => {
mockLayoutManager.isOnline = false;

renderComponent({ layout: unmodifiedOrgLayout, onRefetch: jest.fn() });

fireEvent.click(screen.getByTestId("layout-actions"));

expect(screen.getByTestId("refetch-layout")).toBeDisabled();
});

it("Given multi-selection, when menu is opened on an org layout, then refetch is disabled", () => {
renderComponent({
layout: unmodifiedOrgLayout,
onRefetch: jest.fn(),
multiSelectedIds: [BasicBuilder.string(), BasicBuilder.string()],
});

fireEvent.click(screen.getByTestId("layout-actions"));

expect(screen.getByTestId("refetch-layout")).toBeDisabled();
});

it("Given unsaved changes and a confirmed dialog, when refetch is clicked, then it refetches", async () => {
const modifiedOrgLayout = LayoutBuilder.layout({
permission: "ORG_WRITE",
working: LayoutBuilder.baseline(),
});
const onRefetch = jest.fn();
mockConfirm.mockResolvedValue("ok");

renderComponent({ layout: modifiedOrgLayout, onRefetch });

fireEvent.click(screen.getByTestId("layout-actions"));
fireEvent.click(screen.getByTestId("refetch-layout"));

await waitFor(() => {
expect(onRefetch).toHaveBeenCalledWith(modifiedOrgLayout);
});
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({ variant: "danger", ok: "Refetch" }),
);
});

it("Given unsaved changes and a dismissed dialog, when refetch is clicked, then nothing is refetched", async () => {
const modifiedOrgLayout = LayoutBuilder.layout({
permission: "ORG_WRITE",
working: LayoutBuilder.baseline(),
});
const onRefetch = jest.fn();
mockConfirm.mockResolvedValue("cancel");

renderComponent({ layout: modifiedOrgLayout, onRefetch });

fireEvent.click(screen.getByTestId("layout-actions"));
fireEvent.click(screen.getByTestId("refetch-layout"));

await waitFor(() => {
expect(mockConfirm).toHaveBeenCalled();
});
expect(onRefetch).not.toHaveBeenCalled();
});
});
41 changes: 41 additions & 0 deletions packages/suite-base/src/components/LayoutBrowser/LayoutRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default React.memo(function LayoutRow({
onRevert,
onMakePersonalCopy,
onPublishToCatalog,
onRefetch,
}: {
layout: Layout;
anySelectedModifiedLayouts: boolean;
Expand All @@ -69,6 +70,10 @@ export default React.memo(function LayoutRow({
/** Promote a shared layout into the read-only organization catalog. Optional:
* only supplied where organization layout storage is available. */
onPublishToCatalog?: (item: Layout) => void;
/** Replace a shared layout with the server's current version, discarding local
* changes. Optional: only supplied where organization layout storage is
* available. */
onRefetch?: (item: Layout) => void;
}): React.JSX.Element {
const isMounted = useMountedState();
const [confirm, confirmModal] = useConfirm();
Expand Down Expand Up @@ -122,6 +127,27 @@ export default React.memo(function LayoutRow({
onRevert(layout);
}, [confirm, layout, multiSelection, onRevert]);

const confirmRefetch = useCallback(async () => {
if (!onRefetch) {
return;
}
// Only worth a dialog when there is something to lose; an unmodified layout
// just reloads.
if (hasModifications) {
const response = await confirm({
title: `Refetch “${layout.name}” from the server?`,
prompt:
"Your unsaved changes to this layout will be permanently discarded. This cannot be undone.",
ok: "Refetch",
variant: "danger",
});
if (response !== "ok" || !isMounted()) {
return;
}
}
onRefetch(layout);
}, [confirm, hasModifications, isMounted, layout, onRefetch]);

const renameAction = useCallback(() => {
setNameFieldValue(layout.name);
setEditingName(true);
Expand Down Expand Up @@ -267,6 +293,21 @@ export default React.memo(function LayoutRow({
disabled: !isOnline || multiSelection,
secondaryText: !isOnline ? "Offline" : undefined,
},
// Deliberately not gated on isReadOnlyCatalogLayout: refetching is a read,
// so it stays available on catalog layouts the user cannot write to — which
// is where a stale local copy is most likely to strand them.
layoutIsShared(layout) &&
onRefetch != undefined && {
type: "item",
key: "refetch",
text: "Refetch from server",
onClick: () => {
void confirmRefetch();
},
"data-testid": "refetch-layout",
disabled: !isOnline || multiSelection,
secondaryText: !isOnline ? "Offline" : undefined,
},
{
type: "item",
key: "export",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function LayoutSection({
onRevert,
onMakePersonalCopy,
onPublishToCatalog,
onRefetch,
}: Readonly<{
title: string | undefined;
disablePadding?: boolean;
Expand All @@ -55,6 +56,7 @@ export default function LayoutSection({
onRevert: (item: Layout) => void;
onMakePersonalCopy: (item: Layout) => void;
onPublishToCatalog?: (item: Layout) => void;
onRefetch?: (item: Layout) => void;
}>): React.JSX.Element {
const { classes, cx } = useLayoutSectionStyles();

Expand Down Expand Up @@ -103,6 +105,7 @@ export default function LayoutSection({
onRevert={onRevert}
onMakePersonalCopy={onMakePersonalCopy}
onPublishToCatalog={onPublishToCatalog}
onRefetch={onRefetch}
/>
))}
</List>
Expand Down
60 changes: 58 additions & 2 deletions packages/suite-base/src/components/LayoutBrowser/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
// SPDX-License-Identifier: MPL-2.0

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

import { LayoutSelectionState } from "@lichtblick/suite-base/components/LayoutBrowser/types";
Expand Down Expand Up @@ -100,6 +100,7 @@ jest.mock("@lichtblick/suite-base/hooks/useLayoutActions", () => ({
onDeleteLayout: jest.fn(),
onRevertLayout: jest.fn(),
onOverwriteLayout: jest.fn(),
onRefetchLayout: jest.fn(),
confirmModal: undefined,
}),
}));
Expand All @@ -110,9 +111,18 @@ jest.mock("./LayoutSection", () => ({
}));

jest.mock("@lichtblick/suite-base/components/SidebarContent", () => ({
SidebarContent: ({ children, title }: { children: React.ReactNode; title: string }) => (
SidebarContent: ({
children,
title,
trailingItems,
}: {
children: React.ReactNode;
title: string;
trailingItems?: React.ReactNode[];
}) => (
<div data-testid="sidebar-content">
<span>{title}</span>
{trailingItems}
{children}
</div>
),
Expand Down Expand Up @@ -550,4 +560,50 @@ describe("LayoutBrowser", () => {
await expect(capturePublishHandler()(layout)).rejects.toThrow(/organization layout storage/i);
});
});

describe("refresh button", () => {
afterEach(() => {
mockLayoutManager.supportsSharing = false;
});

it("is hidden without organization layout storage", () => {
mockLayoutManager.supportsSharing = false;

render(<LayoutBrowser />);

expect(screen.queryByTestId("refresh-layouts")).not.toBeInTheDocument();
});

it("syncs with the server when clicked", async () => {
mockLayoutManager.supportsSharing = true;
mockLayoutManager.syncWithRemote = jest.fn().mockResolvedValue(undefined);

render(<LayoutBrowser />);
fireEvent.click(screen.getByTestId("refresh-layouts"));

await waitFor(() => {
expect(mockLayoutManager.syncWithRemote).toHaveBeenCalledTimes(1);
});
});

it("is disabled while offline", () => {
mockLayoutManager.supportsSharing = true;
(useLayoutNavigation as jest.Mock).mockReturnValue({
onSelectLayout: jest.fn(),
state: {
busy: false,
error: undefined,
online: false,
lastSelectedId: undefined,
multiAction: undefined,
selectedIds: [],
},
dispatch: dispatchMock,
});

render(<LayoutBrowser />);

expect(screen.getByTestId("refresh-layouts")).toBeDisabled();
});
});
});
24 changes: 24 additions & 0 deletions packages/suite-base/src/components/LayoutBrowser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import AddIcon from "@mui/icons-material/Add";
import CloudOffIcon from "@mui/icons-material/CloudOff";
import FileOpenOutlinedIcon from "@mui/icons-material/FileOpenOutlined";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
CircularProgress,
Divider,
Expand Down Expand Up @@ -86,6 +87,7 @@ export default function LayoutBrowser({
onDeleteLayout,
onRevertLayout,
onOverwriteLayout,
onRefetchLayout,
confirmModal,
} = useLayoutActions({ state, dispatch });
const { importLayout, exportLayout } = useLayoutTransfer();
Expand Down Expand Up @@ -306,6 +308,13 @@ export default function LayoutBrowser({
[remoteLayoutStorage, enqueueSnackbar],
);

// The periodic sync loop can be up to a few minutes away from its next run, so
// this is the "pull whatever the org has right now" button. Non-destructive by
// design: unlike a row's refetch it never discards anyone's working copy.
const refreshLayouts = useCallbackWithToast(async () => {
await layoutManager.syncWithRemote(new AbortController().signal);
}, [layoutManager]);

const showSignInPrompt =
signIn != undefined && !layoutManager.supportsSharing && !hideSignInPrompt;

Expand Down Expand Up @@ -352,6 +361,19 @@ export default function LayoutBrowser({
>
<FileOpenOutlinedIcon />
</IconButton>,
layoutManager.supportsSharing && (
<IconButton
color="primary"
key="refresh-layouts"
onClick={refreshLayouts}
disabled={!state.online || state.busy}
aria-label="Refresh layouts"
data-testid="refresh-layouts"
title="Refresh layouts from the server"
>
<RefreshIcon />
</IconButton>
),
].filter(Boolean)}
>
{promptModal}
Expand Down Expand Up @@ -402,6 +424,7 @@ export default function LayoutBrowser({
onRevert={onRevertLayout}
onMakePersonalCopy={onMakePersonalCopy}
onPublishToCatalog={onPublishToCatalog}
onRefetch={onRefetchLayout}
/>
{layoutManager.supportsSharing && (
<LayoutSection
Expand All @@ -424,6 +447,7 @@ export default function LayoutBrowser({
onRevert={onRevertLayout}
onMakePersonalCopy={onMakePersonalCopy}
onPublishToCatalog={onPublishToCatalog}
onRefetch={onRefetchLayout}
/>
)}
{!enableNewTopNav && <Stack flexGrow={1} />}
Expand Down
13 changes: 13 additions & 0 deletions packages/suite-base/src/hooks/useLayoutActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,17 @@ describe("useLayoutActions", () => {
expect(mockLayoutManager.overwriteLayout).not.toHaveBeenCalled();
});
});

describe("onRefetchLayout", () => {
it("refetches the layout from the server", async () => {
const { result } = setup();
const mockLayout = LayoutBuilder.layout({ permission: "ORG_WRITE" });

await act(async () => {
await result.current.onRefetchLayout(mockLayout);
});

expect(mockLayoutManager.refetchLayout).toHaveBeenCalledWith({ id: mockLayout.id });
});
});
});
Loading
Loading