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..56dcf85d12e 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,172 @@ 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 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(); + 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..77fb64855dc 100644 --- a/packages/suite-base/src/services/LayoutManager/LayoutManager.ts +++ b/packages/suite-base/src/services/LayoutManager/LayoutManager.ts @@ -425,6 +425,65 @@ 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, + // 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, + }), + ); + + // 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(); }