From 84e094d045716250a0f50a9035f5f2c4a3077933 Mon Sep 17 00:00:00 2001 From: rahulkatiyar19955 Date: Sun, 9 Aug 2026 16:09:02 +0530 Subject: [PATCH 1/2] feat(LayoutBrowser): add refetch from server for organization layouts Organization layouts had no way to pull the server's current version. The periodic sync refreshes baselines, but it preserves the local working copy and only a revert event re-applies a layout to the viewport, so once a user touches an org layout their stale copy shadows the server's indefinitely. Add LayoutManager.refetchLayout, which replaces a shared layout's cached copy with the server's and discards local changes. It reports the change as a revert so CurrentLayoutProvider re-renders the open layout. Surface it two ways: - "Refetch from server" in a layout row's context menu, confirmed when the layout has unsaved changes. Offered on read-only catalog layouts too, since refetching is a read. - A refresh button in the Layouts sidebar that force-runs syncWithRemote for everything, non-destructive and never discarding a working copy. refetchLayout and syncWithRemote are now declared on ILayoutManager; the latter already existed on the concrete class. --- .../LayoutBrowser/LayoutRow.test.tsx | 123 +++++++++++++++ .../components/LayoutBrowser/LayoutRow.tsx | 41 +++++ .../LayoutBrowser/LayoutSection.tsx | 3 + .../components/LayoutBrowser/index.test.tsx | 60 +++++++- .../src/components/LayoutBrowser/index.tsx | 24 +++ .../src/hooks/useLayoutActions.test.tsx | 13 ++ .../suite-base/src/hooks/useLayoutActions.tsx | 11 ++ .../CurrentLayoutProvider/index.test.tsx | 2 + .../suite-base/src/services/ILayoutManager.ts | 12 ++ .../LayoutManager/LayoutManager.test.ts | 144 ++++++++++++++++++ .../services/LayoutManager/LayoutManager.ts | 54 +++++++ .../LayoutManager/MockLayoutManager.ts | 1 + 12 files changed, 486 insertions(+), 2 deletions(-) diff --git a/packages/suite-base/src/components/LayoutBrowser/LayoutRow.test.tsx b/packages/suite-base/src/components/LayoutBrowser/LayoutRow.test.tsx index 37888a72358..338be400371 100644 --- a/packages/suite-base/src/components/LayoutBrowser/LayoutRow.test.tsx +++ b/packages/suite-base/src/components/LayoutBrowser/LayoutRow.test.tsx @@ -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(); + }); +}); diff --git a/packages/suite-base/src/components/LayoutBrowser/LayoutRow.tsx b/packages/suite-base/src/components/LayoutBrowser/LayoutRow.tsx index 5038a735f9f..8d100a72e96 100644 --- a/packages/suite-base/src/components/LayoutBrowser/LayoutRow.tsx +++ b/packages/suite-base/src/components/LayoutBrowser/LayoutRow.tsx @@ -52,6 +52,7 @@ export default React.memo(function LayoutRow({ onRevert, onMakePersonalCopy, onPublishToCatalog, + onRefetch, }: { layout: Layout; anySelectedModifiedLayouts: boolean; @@ -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(); @@ -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); @@ -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", diff --git a/packages/suite-base/src/components/LayoutBrowser/LayoutSection.tsx b/packages/suite-base/src/components/LayoutBrowser/LayoutSection.tsx index ae02c024b1e..c56fc8754c9 100644 --- a/packages/suite-base/src/components/LayoutBrowser/LayoutSection.tsx +++ b/packages/suite-base/src/components/LayoutBrowser/LayoutSection.tsx @@ -35,6 +35,7 @@ export default function LayoutSection({ onRevert, onMakePersonalCopy, onPublishToCatalog, + onRefetch, }: Readonly<{ title: string | undefined; disablePadding?: boolean; @@ -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(); @@ -103,6 +105,7 @@ export default function LayoutSection({ onRevert={onRevert} onMakePersonalCopy={onMakePersonalCopy} onPublishToCatalog={onPublishToCatalog} + onRefetch={onRefetch} /> ))} diff --git a/packages/suite-base/src/components/LayoutBrowser/index.test.tsx b/packages/suite-base/src/components/LayoutBrowser/index.test.tsx index 858ac3fbc0c..98cbb288319 100644 --- a/packages/suite-base/src/components/LayoutBrowser/index.test.tsx +++ b/packages/suite-base/src/components/LayoutBrowser/index.test.tsx @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) // 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"; @@ -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, }), })); @@ -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[]; + }) => (
{title} + {trailingItems} {children}
), @@ -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(); + + 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(); + 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(); + + expect(screen.getByTestId("refresh-layouts")).toBeDisabled(); + }); + }); }); diff --git a/packages/suite-base/src/components/LayoutBrowser/index.tsx b/packages/suite-base/src/components/LayoutBrowser/index.tsx index 8fc1d012594..68c1b8bc735 100644 --- a/packages/suite-base/src/components/LayoutBrowser/index.tsx +++ b/packages/suite-base/src/components/LayoutBrowser/index.tsx @@ -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, @@ -86,6 +87,7 @@ export default function LayoutBrowser({ onDeleteLayout, onRevertLayout, onOverwriteLayout, + onRefetchLayout, confirmModal, } = useLayoutActions({ state, dispatch }); const { importLayout, exportLayout } = useLayoutTransfer(); @@ -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; @@ -352,6 +361,19 @@ export default function LayoutBrowser({ > , + layoutManager.supportsSharing && ( + + + + ), ].filter(Boolean)} > {promptModal} @@ -402,6 +424,7 @@ export default function LayoutBrowser({ onRevert={onRevertLayout} onMakePersonalCopy={onMakePersonalCopy} onPublishToCatalog={onPublishToCatalog} + onRefetch={onRefetchLayout} /> {layoutManager.supportsSharing && ( )} {!enableNewTopNav && } diff --git a/packages/suite-base/src/hooks/useLayoutActions.test.tsx b/packages/suite-base/src/hooks/useLayoutActions.test.tsx index 4847437c322..08c72bc8fb6 100644 --- a/packages/suite-base/src/hooks/useLayoutActions.test.tsx +++ b/packages/suite-base/src/hooks/useLayoutActions.test.tsx @@ -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 }); + }); + }); }); diff --git a/packages/suite-base/src/hooks/useLayoutActions.tsx b/packages/suite-base/src/hooks/useLayoutActions.tsx index dcf29717cf4..b8f3bac603e 100644 --- a/packages/suite-base/src/hooks/useLayoutActions.tsx +++ b/packages/suite-base/src/hooks/useLayoutActions.tsx @@ -21,6 +21,7 @@ type UseLayoutActions = { onDeleteLayout: (item: Layout) => Promise; onRevertLayout: (item: Layout) => Promise; onOverwriteLayout: (item: Layout) => Promise; + onRefetchLayout: (item: Layout) => Promise; confirmModal: React.JSX.Element | undefined; }; @@ -135,12 +136,22 @@ export function useLayoutActions({ state, dispatch }: LayoutSetupOptions): UseLa [analytics, dispatch, layoutManager, state.selectedIds.length], ); + // Discarding local changes is confirmed by the caller, alongside the other + // destructive row actions. + const onRefetchLayout = useCallbackWithToast( + async (item: Layout) => { + await layoutManager.refetchLayout({ id: item.id }); + }, + [layoutManager], + ); + return { onRenameLayout, onDuplicateLayout, onDeleteLayout, onRevertLayout, onOverwriteLayout, + onRefetchLayout, confirmModal, }; } diff --git a/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx b/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx index f1c5f91df8b..2c0a41c4571 100644 --- a/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx +++ b/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx @@ -76,7 +76,9 @@ function makeMockLayoutManager() { deleteLayout: jest.fn().mockImplementation(mockThrow("deleteLayout")), overwriteLayout: jest.fn().mockImplementation(mockThrow("overwriteLayout")), revertLayout: jest.fn().mockImplementation(mockThrow("revertLayout")), + refetchLayout: jest.fn().mockImplementation(mockThrow("refetchLayout")), makePersonalCopy: jest.fn().mockImplementation(mockThrow("makePersonalCopy")), + syncWithRemote: jest.fn().mockImplementation(mockThrow("syncWithRemote")), }; } function makeMockUserProfile() { diff --git a/packages/suite-base/src/services/ILayoutManager.ts b/packages/suite-base/src/services/ILayoutManager.ts index c875d0a2243..3e9d54bffbd 100644 --- a/packages/suite-base/src/services/ILayoutManager.ts +++ b/packages/suite-base/src/services/ILayoutManager.ts @@ -99,6 +99,18 @@ export interface ILayoutManager { /** Revert this layout to the baseline. */ revertLayout(params: { id: LayoutID }): Promise; + /** + * Replace a shared layout's cached copy with the server's current version, + * discarding any local working changes. + */ + refetchLayout(params: { id: LayoutID }): Promise; + /** Transfer a shared layout's working changes into a new personal layout. */ makePersonalCopy(params: { id: LayoutID; name: string }): Promise; + + /** + * Reconcile the local cache with remote storage. Runs periodically on its own; + * exposed here so the user can also ask for it on demand. + */ + syncWithRemote(abortSignal: AbortSignal): Promise; } diff --git a/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts b/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts index 4232b017940..721561c3325 100644 --- a/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts +++ b/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts @@ -5,6 +5,7 @@ import { LayoutID } from "@lichtblick/suite-base/context/CurrentLayoutContext"; import { ILayoutStorage, ISO8601Timestamp, + Layout, LayoutPermission, } from "@lichtblick/suite-base/services/ILayoutStorage"; import { IRemoteLayoutStorage } from "@lichtblick/suite-base/services/IRemoteLayoutStorage"; @@ -1004,6 +1005,149 @@ describe("LayoutManager", () => { }); }); + describe("refetchLayout", () => { + const sharedLayout = (props: Partial = {}) => + LayoutBuilder.layout({ + permission: "ORG_WRITE", + syncInfo: LayoutBuilder.syncInfo({ status: "tracked" }), + ...props, + }); + + it("should throw when no remote storage is configured", async () => { + // Given + const layoutManager = new LayoutManager({ local: mockLocalStorage, remote: undefined }); + layoutManager.setOnline({ online: true }); + + // When & Then + await expect(layoutManager.refetchLayout({ id: LayoutBuilder.layoutId() })).rejects.toThrow( + "Refetching is not supported without remote layout storage", + ); + }); + + it("should throw when offline", async () => { + // Given + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + + // When & Then + await expect(layoutManager.refetchLayout({ id: LayoutBuilder.layoutId() })).rejects.toThrow( + "Cannot refetch a layout while offline", + ); + expect(mockRemoteStorage.getLayouts).not.toHaveBeenCalled(); + }); + + it("should throw when the layout does not exist locally", async () => { + // Given + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + layoutManager.setOnline({ online: true }); + const layoutId = LayoutBuilder.layoutId(); + + // When & Then + await expect(layoutManager.refetchLayout({ id: layoutId })).rejects.toThrow( + `Cannot refetch layout id ${layoutId} because it does not exist`, + ); + }); + + it("should throw for a personal layout, which has no server copy", async () => { + // Given + const layout = sharedLayout({ permission: "CREATOR_WRITE" }); + mockLocalStorage.list.mockResolvedValue([layout]); + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + layoutManager.setOnline({ online: true }); + + // When & Then + await expect(layoutManager.refetchLayout({ id: layout.id })).rejects.toThrow( + "Only organization layouts can be refetched", + ); + expect(mockRemoteStorage.getLayouts).not.toHaveBeenCalled(); + }); + + it("should throw when the layout is gone from the server", async () => { + // Given + const layout = sharedLayout(); + mockLocalStorage.list.mockResolvedValue([layout]); + mockRemoteStorage.getLayouts.mockResolvedValue([LayoutBuilder.remoteLayout()]); + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + layoutManager.setOnline({ online: true }); + + // When & Then + await expect(layoutManager.refetchLayout({ id: layout.id })).rejects.toThrow( + `“${layout.name}” no longer exists on the server`, + ); + expect(jest.spyOn(mockLocalStorage, "put")).not.toHaveBeenCalled(); + }); + + it("should replace the baseline with the remote copy and discard local changes", async () => { + // Given + const layout = sharedLayout(); + const remoteLayout = LayoutBuilder.remoteLayout({ + id: layout.id, + permission: "ORG_WRITE", + }); + mockLocalStorage.list.mockResolvedValue([layout]); + mockRemoteStorage.getLayouts.mockResolvedValue([remoteLayout]); + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + layoutManager.setOnline({ online: true }); + + // When + const result = await layoutManager.refetchLayout({ id: layout.id }); + + // Then + expect(result.working).toBeUndefined(); + expect(result.name).toBe(remoteLayout.name); + expect(result.externalId).toBe(remoteLayout.externalId); + expect(result.baseline).toEqual({ + data: remoteLayout.data, + savedAt: remoteLayout.savedAt, + }); + expect(result.syncInfo).toEqual({ + status: "tracked", + lastRemoteSavedAt: remoteLayout.savedAt, + }); + }); + + it("should report the change as a revert so the open layout is re-rendered", async () => { + // Given + const layout = sharedLayout(); + const remoteLayout = LayoutBuilder.remoteLayout({ id: layout.id, permission: "ORG_READ" }); + mockLocalStorage.list.mockResolvedValue([layout]); + mockRemoteStorage.getLayouts.mockResolvedValue([remoteLayout]); + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + layoutManager.setOnline({ online: true }); + const changeListener = jest.fn(); + layoutManager.on("change", changeListener); + + // When + await layoutManager.refetchLayout({ id: layout.id }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Then + expect(changeListener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "revert", + updatedLayout: expect.objectContaining({ id: layout.id, working: undefined }), + }), + ); + }); + }); + describe("syncWithRemote", () => { it("should do nothing when no remote storage is configured", async () => { // Given diff --git a/packages/suite-base/src/services/LayoutManager/LayoutManager.ts b/packages/suite-base/src/services/LayoutManager/LayoutManager.ts index 54573ecde3e..2c362f7867b 100644 --- a/packages/suite-base/src/services/LayoutManager/LayoutManager.ts +++ b/packages/suite-base/src/services/LayoutManager/LayoutManager.ts @@ -425,6 +425,60 @@ export default class LayoutManager implements ILayoutManager { return result; } + /** + * Replace a shared layout's cached copy with the server's current version, + * discarding any local working changes. + * + * `syncWithRemote` also refreshes baselines, but it preserves `working` and + * runs on its own schedule, so once the user has touched a layout their stale + * working copy shadows the server version indefinitely. This is the explicit + * "give me what the server has" path. + */ + @emitBusyStatus + public async refetchLayout({ id }: { id: LayoutID }): Promise { + if (!this.remote) { + throw new Error("Refetching is not supported without remote layout storage"); + } + if (!this.isOnline) { + throw new Error("Cannot refetch a layout while offline"); + } + + const localLayout = await this.local.runExclusive(async (local) => await local.get(id)); + if (!localLayout) { + throw new Error(`Cannot refetch layout id ${id} because it does not exist`); + } + if (!layoutIsShared(localLayout)) { + throw new Error("Only organization layouts can be refetched"); + } + + // The list endpoint rather than getLayout: only some IRemoteLayoutStorage + // implementations support fetching a single layout by id. + const remoteLayouts = await this.remote.getLayouts(); + const remoteLayout = remoteLayouts.find((candidate) => candidate.id === id); + if (!remoteLayout) { + throw new Error(`“${localLayout.name}” no longer exists on the server`); + } + + const result = await this.local.runExclusive( + async (local) => + await local.put({ + id: remoteLayout.id, + externalId: remoteLayout.externalId, + name: remoteLayout.name, + permission: remoteLayout.permission, + baseline: { data: remoteLayout.data, savedAt: remoteLayout.savedAt }, + working: undefined, + syncInfo: { status: "tracked", lastRemoteSavedAt: remoteLayout.savedAt }, + }), + ); + + // Reported as "revert", not "change": CurrentLayoutProvider re-applies the + // selected layout only for revert events, and a refetch that leaves the + // viewport rendering the old layout would look like it did nothing. + this.notifyChangeListeners({ type: "revert", updatedLayout: result }); + return result; + } + @emitBusyStatus public async makePersonalCopy({ id, name }: { id: LayoutID; name: string }): Promise { const now = new Date().toISOString() as ISO8601Timestamp; diff --git a/packages/suite-base/src/services/LayoutManager/MockLayoutManager.ts b/packages/suite-base/src/services/LayoutManager/MockLayoutManager.ts index 7c0a865bf34..6dc82998d6b 100644 --- a/packages/suite-base/src/services/LayoutManager/MockLayoutManager.ts +++ b/packages/suite-base/src/services/LayoutManager/MockLayoutManager.ts @@ -24,6 +24,7 @@ export default class MockLayoutManager implements ILayoutManager { public deleteLayout = jest.fn(); public overwriteLayout = jest.fn(); public revertLayout = jest.fn(); + public refetchLayout = jest.fn(); public makePersonalCopy = jest.fn(); public syncWithRemote = jest.fn(); } From e2538da63fbae56efcecc971020649fd213699db Mon Sep 17 00:00:00 2001 From: rahulkatiyar19955 Date: Sun, 9 Aug 2026 16:20:58 +0530 Subject: [PATCH 2/2] fix(LayoutManager): don't attach syncInfo to a personal layout on refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refetchLayout wrote syncInfo.status = "tracked" regardless of the permission the server returned. A row that comes back as CREATOR_WRITE would then be stored as a personal layout carrying syncInfo, and the sync reducer treats a tracked layout missing from the remote list as delete-local — silently dropping it from the cache. Key the syncInfo off the returned permission, matching add-to-cache. --- .../LayoutManager/LayoutManager.test.ts | 23 +++++++++++++++++++ .../services/LayoutManager/LayoutManager.ts | 7 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts b/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts index 721561c3325..56dcf85d12e 100644 --- a/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts +++ b/packages/suite-base/src/services/LayoutManager/LayoutManager.test.ts @@ -1120,6 +1120,29 @@ describe("LayoutManager", () => { }); }); + it("should not attach syncInfo when the server reports the layout as personal", async () => { + // Given + const layout = sharedLayout(); + const remoteLayout = LayoutBuilder.remoteLayout({ + id: layout.id, + permission: "CREATOR_WRITE", + }); + mockLocalStorage.list.mockResolvedValue([layout]); + mockRemoteStorage.getLayouts.mockResolvedValue([remoteLayout]); + const layoutManager = new LayoutManager({ + local: mockLocalStorage, + remote: mockRemoteStorage, + }); + layoutManager.setOnline({ online: true }); + + // When + const result = await layoutManager.refetchLayout({ id: layout.id }); + + // Then + expect(result.permission).toBe("CREATOR_WRITE"); + expect(result.syncInfo).toBeUndefined(); + }); + it("should report the change as a revert so the open layout is re-rendered", async () => { // Given const layout = sharedLayout(); diff --git a/packages/suite-base/src/services/LayoutManager/LayoutManager.ts b/packages/suite-base/src/services/LayoutManager/LayoutManager.ts index 2c362f7867b..77fb64855dc 100644 --- a/packages/suite-base/src/services/LayoutManager/LayoutManager.ts +++ b/packages/suite-base/src/services/LayoutManager/LayoutManager.ts @@ -468,7 +468,12 @@ export default class LayoutManager implements ILayoutManager { permission: remoteLayout.permission, baseline: { data: remoteLayout.data, savedAt: remoteLayout.savedAt }, working: undefined, - syncInfo: { status: "tracked", lastRemoteSavedAt: remoteLayout.savedAt }, + // Keyed off the permission the server just returned, not the one the + // layout had locally: a personal layout must never carry syncInfo, or + // the sync reducer becomes free to delete it. Same rule as add-to-cache. + syncInfo: layoutPermissionIsShared(remoteLayout.permission) + ? { status: "tracked", lastRemoteSavedAt: remoteLayout.savedAt } + : undefined, }), );