Skip to content

Commit

Permalink
Feature/load default layouts from local folder (#113)
Browse files Browse the repository at this point in the history
**User-Facing Changes**
This pull request introduces the ability for the software to
automatically load "default layouts" from the user's
`~/.lichtblick/layouts` directory upon startup. This feature ensures
that users have access to predefined layouts immediately after launching
the application (desktop version).

**Description**
- Added functionality to load "default layouts" from the
`~/.lichtblick/layouts` directory. This enables the software to open
with pre-configured layouts already available.
- Implemented a check to prevent duplicate uploads based on file names.
If a file is renamed, it will be loaded again. If the file content is
changed but the name remains the same, the software will not detect the
change.
- The software will load and save the files in memory. The layout
functionality remains the same as before. New layouts created within the
application will not be saved in the `~/.lichtblick/layouts` directory.
- This feature works only for the desktop version, because modern
browsers (web version) are not allowed to access local files, for
security reasons.

**Checklist**

- [x] The web version was tested and it is running ok
- [x] The desktop version was tested and it is running ok
- [x] The release version was updated on package.json files
  • Loading branch information
aneuwald-ctw authored Jul 29, 2024
1 parent e6ed419 commit aedae39
Show file tree
Hide file tree
Showing 20 changed files with 1,144 additions and 397 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lichtblick",
"version": "1.2.1",
"version": "1.3.0",
"license": "MPL-2.0",
"private": true,
"productName": "Lichtblick",
Expand Down
5 changes: 4 additions & 1 deletion packages/studio-base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ProblemsContextProvider from "@foxglove/studio-base/providers/ProblemsCon
import { StudioLogsSettingsProvider } from "@foxglove/studio-base/providers/StudioLogsSettingsProvider";
import TimelineInteractionStateProvider from "@foxglove/studio-base/providers/TimelineInteractionStateProvider";
import UserProfileLocalStorageProvider from "@foxglove/studio-base/providers/UserProfileLocalStorageProvider";
import { LayoutLoader } from "@foxglove/studio-base/services/ILayoutLoader";

import Workspace from "./Workspace";
import { CustomWindowControlsProps } from "./components/AppBar/CustomWindowControls";
Expand Down Expand Up @@ -43,6 +44,7 @@ type AppProps = CustomWindowControlsProps & {
appConfiguration: IAppConfiguration;
dataSources: IDataSourceFactory[];
extensionLoaders: readonly ExtensionLoader[];
layoutLoaders: readonly LayoutLoader[];
nativeAppMenu?: INativeAppMenu;
nativeWindow?: INativeWindow;
enableLaunchPreferenceScreen?: boolean;
Expand All @@ -67,6 +69,7 @@ export function App(props: AppProps): JSX.Element {
appConfiguration,
dataSources,
extensionLoaders,
layoutLoaders,
nativeAppMenu,
nativeWindow,
deepLinks,
Expand Down Expand Up @@ -106,7 +109,7 @@ export function App(props: AppProps): JSX.Element {
providers.unshift(<ProblemsContextProvider />);
providers.unshift(<CurrentLayoutProvider />);
providers.unshift(<UserProfileLocalStorageProvider />);
providers.unshift(<LayoutManagerProvider />);
providers.unshift(<LayoutManagerProvider loaders={layoutLoaders} />);

const layoutStorage = useMemo(() => new IdbLayoutStorage(), []);
providers.unshift(<LayoutStorageContext.Provider value={layoutStorage} />);
Expand Down
3 changes: 3 additions & 0 deletions packages/studio-base/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export { default as overwriteFetch } from "./util/overwriteFetch";
export { default as waitForFonts } from "./util/waitForFonts";
export { initI18n } from "./i18n";
export type { ExtensionLoader } from "./services/ExtensionLoader";
export type { LayoutLoader } from "./services/ILayoutLoader";
export type { LayoutInfo } from "./types/layouts";
export type { LayoutData } from "./context/CurrentLayoutContext";
export type { ExtensionInfo, ExtensionNamespace } from "./types/Extensions";
export { AppSetting } from "./AppSetting";
export { default as FoxgloveWebSocketDataSourceFactory } from "./dataSources/FoxgloveWebSocketDataSourceFactory";
Expand Down
304 changes: 304 additions & 0 deletions packages/studio-base/src/providers/LayoutManagerProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
/** @jest-environment jsdom */
// This Source Code Form is subject to the terms of the Mozilla Public
// 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 { render, waitFor } from "@testing-library/react";
import { useNetworkState } from "react-use";

import { useVisibilityState } from "@foxglove/hooks";
import { LayoutData } from "@foxglove/studio-base/context/CurrentLayoutContext";
import { useLayoutStorage } from "@foxglove/studio-base/context/LayoutStorageContext";
import { useRemoteLayoutStorage } from "@foxglove/studio-base/context/RemoteLayoutStorageContext";
import LayoutManagerProvider from "@foxglove/studio-base/providers/LayoutManagerProvider";
import { LayoutLoader } from "@foxglove/studio-base/services/ILayoutLoader";
import MockLayoutManager from "@foxglove/studio-base/services/LayoutManager/MockLayoutManager";

// Mock dependencies
jest.mock("react-use");
jest.mock("@foxglove/hooks");
jest.mock("@foxglove/studio-base/context/LayoutStorageContext");
jest.mock("@foxglove/studio-base/context/RemoteLayoutStorageContext");

const mockLayoutManager = new MockLayoutManager();

jest.mock("@foxglove/studio-base/services/LayoutManager/LayoutManager", () =>
jest.fn(() => mockLayoutManager),
);

describe("LayoutManagerProvider", () => {
const mockLayoutLoader: jest.Mocked<LayoutLoader> = {
fetchLayouts: jest.fn(),
namespace: "local",
};

// Mock necessary hooks to render <LayoutManagerProvider /> component, otherwise it will fail
(useNetworkState as jest.Mock).mockReturnValue({ online: true });
(useVisibilityState as jest.Mock).mockReturnValue("visible");
(useLayoutStorage as jest.Mock).mockReturnValue({});
(useRemoteLayoutStorage as jest.Mock).mockReturnValue({});

const consoleErrorMock = console.error as ReturnType<typeof jest.fn>;

beforeEach(() => {
jest.clearAllMocks();
});

it("should call layoutManager.setOnline accordingly with useNetworkState", async () => {
(useNetworkState as jest.Mock).mockResolvedValueOnce({ online: false });

// 1 render with true and another with false.
render(<LayoutManagerProvider />);
render(<LayoutManagerProvider />);

await waitFor(() => {
expect(mockLayoutManager.setOnline).toHaveBeenCalledTimes(2);
expect(mockLayoutManager.setOnline).toHaveBeenCalledWith(true);
expect(mockLayoutManager.setOnline).toHaveBeenCalledWith(false);
});
});

it("should not call getLayouts or saveNewLayout if layout loaders is undefined or an empty array", async () => {
// 1 render with no loaders and another with empty array.
render(<LayoutManagerProvider />);
render(<LayoutManagerProvider loaders={[]} />);

await waitFor(() => {
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(0);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(0);
});
});

it("should not call layoutManager.saveNewLayout if there is no layouts", async () => {
mockLayoutLoader.fetchLayouts.mockResolvedValueOnce([]);

render(<LayoutManagerProvider loaders={[mockLayoutLoader]} />);

await waitFor(() => {
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(0);
});
});

it("should fetch layouts from loaders and save the new layouts", async () => {
mockLayoutLoader.fetchLayouts.mockResolvedValueOnce([
{ from: "layout1.json", name: "layout1", data: {} as LayoutData },
{ from: "layout2.json", name: "layout2", data: {} as LayoutData },
]);

render(<LayoutManagerProvider loaders={[mockLayoutLoader]} />);

await waitFor(() => {
// Should be called 2 times, because only one loader.
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(1);

// Should be called 1 time, once for all loaders.
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);

// Expect all layouts to be saved.
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(2);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout1.json", name: "layout1" }),
);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout2.json", name: "layout2" }),
);
});
});

it("should fetch layouts from multiple loaders and save the new layouts", async () => {
// Mock two loaders with different layouts.
mockLayoutLoader.fetchLayouts
.mockResolvedValueOnce([
{ from: "layout1.json", name: "layout1", data: {} as LayoutData },
{ from: "layout2.json", name: "layout2", data: {} as LayoutData },
])
.mockResolvedValueOnce([
{ from: "layout3.json", name: "layout3", data: {} as LayoutData },
{ from: "layout4.json", name: "layout4", data: {} as LayoutData },
]);

render(<LayoutManagerProvider loaders={[mockLayoutLoader, mockLayoutLoader]} />);

await waitFor(() => {
// Should be called 2 times, one per loader.
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(2);

// Should be called 1 time, once for all loaders.
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);

// Expect all layouts to be saved.
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(4);

expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout1.json", name: "layout1" }),
);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout2.json", name: "layout2" }),
);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout3.json", name: "layout3" }),
);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout4.json", name: "layout4" }),
);
});
});

it("should fetch layouts from loaders and not save duplicated layouts", async () => {
// Make layouts with same name, but different "from"
mockLayoutLoader.fetchLayouts.mockResolvedValueOnce([
{ from: "layout1.json", name: "layout", data: {} as LayoutData },
{ from: "layout2.json", name: "layout", data: {} as LayoutData },
{ from: "layout3.json", name: "layout", data: {} as LayoutData },
]);

// Mock an existing layout with "from" equals to "layout2"
mockLayoutManager.getLayouts.mockResolvedValueOnce([{ from: "layout2.json", name: "layout" }]);

render(<LayoutManagerProvider loaders={[mockLayoutLoader]} />);

await waitFor(() => {
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);

// Expect only "layout1.json" and "layout3.json" to be saved.
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(2);

expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout1.json", name: "layout" }),
);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledWith(
expect.objectContaining({ from: "layout3.json", name: "layout" }),
);

// Expect "layout2.json" to not be saved, because it has been already loaded.
expect(mockLayoutManager.saveNewLayout).not.toHaveBeenCalledWith(
expect.objectContaining({ from: "layout2.json", name: "layout" }),
);
});
});

it("should log the correct error when fetchLayouts fails", async () => {
const errorMessage = "Failed to fetch layouts";
const expectedError = `Failed to fetch layouts from loader: ${errorMessage}`;

mockLayoutLoader.fetchLayouts.mockRejectedValueOnce(errorMessage);

render(<LayoutManagerProvider loaders={[mockLayoutLoader]} />);

await waitFor(() => {
expect(consoleErrorMock.mock.calls[0]).toContain(expectedError);
consoleErrorMock.mockClear();
});
});

it("should log the correct error when saveNewLayout fails", async () => {
const errorMessage = "Failed to save layout";
const expectedError = `Failed to save layout: ${errorMessage}`;

mockLayoutLoader.fetchLayouts.mockResolvedValueOnce([
{ from: "layout1.json", name: "layout1", data: {} as LayoutData },
]);

mockLayoutManager.saveNewLayout.mockRejectedValueOnce(errorMessage);

render(<LayoutManagerProvider loaders={[mockLayoutLoader]} />);

await waitFor(() => {
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(1);
expect(consoleErrorMock.mock.calls[0]).toContain(expectedError);
consoleErrorMock.mockClear();
});
});

it("should log the correct error when the entire loadAndSaveLayouts process fails", async () => {
const errorMessage = "General loading error";
const expectedError = `Loading default layouts failed: ${errorMessage}`;

mockLayoutManager.getLayouts.mockRejectedValueOnce(errorMessage);

render(<LayoutManagerProvider loaders={[mockLayoutLoader]} />);

await waitFor(() => {
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(0);
expect(consoleErrorMock.mock.calls[0]).toContain(expectedError);
consoleErrorMock.mockClear();
});
});

it("should handle partial successes and log correct errors", async () => {
const fetchErrorMessage = "Fetch failed";
const expectedFetchError = `Failed to fetch layouts from loader: ${fetchErrorMessage}`;

const saveErrorMessage = "Save failed";
const expectedSaveError = `Failed to save layout: ${saveErrorMessage}`;

const layouts = [
{ from: "layout1.json", name: "layout1", data: {} as LayoutData },
{ from: "layout2.json", name: "layout2", data: {} as LayoutData },
];

mockLayoutLoader.fetchLayouts
.mockResolvedValueOnce(layouts)
.mockRejectedValueOnce(fetchErrorMessage);

mockLayoutManager.saveNewLayout
.mockResolvedValueOnce("sucess")
.mockRejectedValueOnce(saveErrorMessage);

render(<LayoutManagerProvider loaders={[mockLayoutLoader, mockLayoutLoader]} />);

await waitFor(() => {
expect(mockLayoutLoader.fetchLayouts).toHaveBeenCalledTimes(2);
expect(mockLayoutManager.getLayouts).toHaveBeenCalledTimes(1);
expect(mockLayoutManager.saveNewLayout).toHaveBeenCalledTimes(2);
expect(consoleErrorMock.mock.calls[0]).toContain(expectedFetchError);
expect(consoleErrorMock.mock.calls[1]).toContain(expectedSaveError);

consoleErrorMock.mockClear();
});
});

it("should call layoutManager.syncWithRemote", async () => {
render(<LayoutManagerProvider />);

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

it("should not call layoutManager.syncWithRemote if offline", async () => {
(useNetworkState as jest.Mock).mockReturnValueOnce({ online: false });

render(<LayoutManagerProvider />);

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

it("should not call layoutManager.syncWithRemote if not visible", async () => {
(useVisibilityState as jest.Mock).mockReturnValueOnce("invisible");

render(<LayoutManagerProvider />);

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

it("should not call layoutManager.syncWithRemote if there is not remote storage", async () => {
(useRemoteLayoutStorage as jest.Mock).mockReturnValueOnce(undefined);

render(<LayoutManagerProvider />);

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

0 comments on commit aedae39

Please sign in to comment.