diff --git a/package-lock.json b/package-lock.json
index 0d9819e..040f122 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"name": "daedalus",
"version": "0.1.22",
"dependencies": {
- "@diamondlightsource/cs-web-lib": "^0.10.21",
+ "@diamondlightsource/cs-web-lib": "^0.10.22",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^7.3.11",
@@ -458,9 +458,9 @@
}
},
"node_modules/@diamondlightsource/cs-web-lib": {
- "version": "0.10.21",
- "resolved": "https://registry.npmjs.org/@diamondlightsource/cs-web-lib/-/cs-web-lib-0.10.21.tgz",
- "integrity": "sha512-HsNpri8rG4l0yGG0aSrENSLVsJPJo+trba83RA+yC5XErXpxGsEF4NIFSqN3bRuyHTWw15Ul6z+E1feT+lwY3Q==",
+ "version": "0.10.22",
+ "resolved": "https://registry.npmjs.org/@diamondlightsource/cs-web-lib/-/cs-web-lib-0.10.22.tgz",
+ "integrity": "sha512-igjn+d0bCWrQzyJyC6m73WgbJtr4CmomkXd8Bc9Gnd5woBMJlpGIuS7iSjA0cKgKMvEcIlV3LAlPW8FoXYbKUQ==",
"license": "ISC",
"dependencies": {
"apollo-link-retry": "^2.2.16",
diff --git a/package.json b/package.json
index 3fb0614..8399118 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@diamondlightsource/cs-web-lib": "^0.10.21",
+ "@diamondlightsource/cs-web-lib": "^0.10.22",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^7.3.11",
diff --git a/src/components/QuickScreenSettings.tsx b/src/components/QuickScreenSettings.tsx
deleted file mode 100644
index 45e44df..0000000
--- a/src/components/QuickScreenSettings.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { styled, useTheme } from "@mui/material/styles";
-import MuiDrawer from "@mui/material/Drawer";
-import List from "@mui/material/List";
-import ListItem from "@mui/material/ListItem";
-import ListItemButton from "@mui/material/ListItemButton";
-import ListItemIcon from "@mui/material/ListItemIcon";
-import AddIcon from "@mui/icons-material/Add";
-import LibraryAddIcon from "@mui/icons-material/LibraryAdd";
-import SaveIcon from "@mui/icons-material/Save";
-import UploadFileIcon from "@mui/icons-material/UploadFile";
-import DeleteIcon from "@mui/icons-material/Delete";
-import { ListItemText, Tooltip } from "@mui/material";
-import { useNavigate } from "react-router";
-
-const NEW_QUICK_SCREEN = {
- path: "/new.bob",
- macros: {},
- defaultProtocol: "ca"
-};
-
-const Drawer = styled(MuiDrawer)(() => ({
- overflowX: "hidden",
- width: "3%",
- minWidth: "50px",
- "& .MuiPaper-root": {
- position: "relative",
- overflowX: "hidden"
- }
-}));
-
-export default function QuickScreenSettings() {
- const theme = useTheme();
- const navigate = useNavigate();
-
- /**
- * Loads a new blank quick screen
- */
- const onClickNew = () => {
- //Change to a blank Quick Screen
- navigate("/quick-screens/", {
- state: { pageState: { quickScreen: NEW_QUICK_SCREEN } },
- replace: true
- });
- };
-
- /**
- * Adds a new screen to the current view to draw components from
- */
- const onClickAdd = () => {
- console.log("clicked add");
- };
-
- /**
- * Saves the current quick screen to local storage
- */
- const onClickSave = () => {
- console.log("clicked save");
- };
-
- /**
- * Loads a quick screen from local storage
- */
- const onClickLoad = () => {
- console.log("clicked load");
- };
-
- /**
- * Loads a quick screen from local storage
- */
- const onClickDelete = () => {
- console.log("clicked delete");
- };
-
- const SETTINGS_LIST = [
- {
- name: "New",
- text: "Create new blank Quick Screen",
- icon: ,
- onClick: onClickNew
- },
- {
- name: "Add",
- text: "Add a .bob file to the view",
- icon: ,
- onClick: onClickAdd
- },
- {
- name: "Save",
- text: "Save the current Quick Screen",
- icon: ,
- onClick: onClickSave
- },
- {
- name: "Load",
- text: "Load a Quick Screen",
- icon: ,
- onClick: onClickLoad
- },
- {
- name: "Delete",
- text: "Discard the current Quick Screen",
- icon: ,
- onClick: onClickDelete
- }
- ];
-
- return (
-
-
- {SETTINGS_LIST.map(item => (
-
-
-
-
- {item.icon}
-
-
-
-
-
- ))}
-
-
- );
-}
diff --git a/src/components/QuickScreens.tsx b/src/components/QuickScreens.tsx
deleted file mode 100644
index f120802..0000000
--- a/src/components/QuickScreens.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Box, Paper as MuiPaper, styled, Typography } from "@mui/material";
-import {
- DynamicPageWidget,
- newRelativePosition
-} from "@diamondlightsource/cs-web-lib";
-import {
- useWindowWidth,
- APP_BAR_HEIGHT,
- useWindowHeight
-} from "../utils/helper";
-import { useRef } from "react";
-import QuickScreenSettings from "./QuickScreenSettings";
-import { useLocation } from "react-router";
-
-const Paper = styled(MuiPaper)(({ theme }) => ({
- height: `calc(${useWindowHeight()}px - ${APP_BAR_HEIGHT}px - 50px)`,
- margin: `calc(${APP_BAR_HEIGHT}px + 15px) 5px 5px 5px`,
- width: `calc(${useWindowWidth()}px - 10px - ${theme.spacing(7)} - 8px)`
-}));
-
-export default function QuickScreens() {
- const displayUuidRef = useRef();
- const location = useLocation();
- const quickScreen = location.state?.pageState?.quickScreen;
-
- return (
-
-
-
- {quickScreen ? (
- {
- // The uuid allows the json representation of the display instance to be selected from the redux store
- displayUuidRef.current = uuid;
- }}
- targetDisplayType="displayGridLayout"
- />
- ) : (
-
- No Quick Screen Loaded
-
- )}
-
-
- );
-}
diff --git a/src/components/QuickScreens/Display.tsx b/src/components/QuickScreens/Display.tsx
new file mode 100644
index 0000000..ca4cf65
--- /dev/null
+++ b/src/components/QuickScreens/Display.tsx
@@ -0,0 +1,66 @@
+import { Box, Paper as MuiPaper, styled, Typography } from "@mui/material";
+import {
+ DynamicPageWidget,
+ newRelativePosition
+} from "@diamondlightsource/cs-web-lib";
+import {
+ useWindowWidth,
+ APP_BAR_HEIGHT,
+ useWindowHeight
+} from "../../utils/helper";
+import { createContext, useState } from "react";
+import QuickScreenSettings from "./Settings";
+import { useLocation } from "react-router";
+
+// Local quick screen storage handler
+export const StorageContext = createContext<{
+ bobDisplayUuid?: string;
+ setBobDisplayUuid: any;
+}>({ bobDisplayUuid: "", setBobDisplayUuid: () => null });
+
+const Paper = styled(MuiPaper)(({ theme }) => ({
+ height: `calc(${useWindowHeight()}px - ${APP_BAR_HEIGHT}px - 50px)`,
+ margin: `calc(${APP_BAR_HEIGHT}px + 15px) 5px 5px 5px`,
+ width: `calc(${useWindowWidth()}px - 10px - ${theme.spacing(7)} - 8px)`
+}));
+
+export default function QuickScreenDisplay() {
+ const [bobDisplayUuid, setBobDisplayUuid] = useState();
+ const location = useLocation();
+ const quickScreen = location.state?.pageState?.quickScreen;
+
+ return (
+
+
+
+
+ {quickScreen ? (
+ {
+ // The uuid allows the json representation of the display instance to be selected from the redux store
+ setBobDisplayUuid(uuid);
+ }}
+ targetDisplayType="displayGridLayout"
+ />
+ ) : (
+
+ No Quick Screen Loaded
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/QuickScreens/Settings.tsx b/src/components/QuickScreens/Settings.tsx
new file mode 100644
index 0000000..741dce9
--- /dev/null
+++ b/src/components/QuickScreens/Settings.tsx
@@ -0,0 +1,209 @@
+import { styled, useTheme } from "@mui/material/styles";
+import MuiDrawer from "@mui/material/Drawer";
+import List from "@mui/material/List";
+import ListItem from "@mui/material/ListItem";
+import ListItemButton from "@mui/material/ListItemButton";
+import ListItemIcon from "@mui/material/ListItemIcon";
+import AddIcon from "@mui/icons-material/Add";
+import LibraryAddIcon from "@mui/icons-material/LibraryAdd";
+import SaveIcon from "@mui/icons-material/Save";
+import UploadFileIcon from "@mui/icons-material/UploadFile";
+import DeleteIcon from "@mui/icons-material/Delete";
+import CloseIcon from "@mui/icons-material/Close";
+import {
+ Dialog as MuiDialog,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ ListItemText,
+ Stack,
+ Tooltip
+} from "@mui/material";
+import { useNavigate } from "react-router";
+import { useState } from "react";
+import LocalStorageBrowser from "./StorageBrowser";
+
+const NEW_QUICK_SCREEN = {
+ path: "/new.bob",
+ macros: {},
+ defaultProtocol: "ca"
+};
+
+const Drawer = styled(MuiDrawer)(() => ({
+ overflowX: "hidden",
+ width: "3%",
+ minWidth: "50px",
+ "& .MuiPaper-root": {
+ position: "relative",
+ overflowX: "hidden"
+ }
+}));
+
+const Dialog = styled(MuiDialog)(({ theme }) => ({
+ "& .MuiDialogContent-root": {
+ padding: theme.spacing(2)
+ },
+ "& .MuiDialogActions-root": {
+ padding: theme.spacing(1)
+ }
+}));
+
+export default function QuickScreenSettings() {
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const [storageModalOpen, setStorageModalOpen] = useState(false);
+
+ const handleCloseModal = (_event: any) => {
+ setStorageModalOpen(false);
+ };
+
+ /**
+ * Loads a new blank quick screen
+ */
+ const onClickNew = () => {
+ //Change to a blank Quick Screen
+ navigate("/quick-screens/", {
+ state: { pageState: { quickScreen: NEW_QUICK_SCREEN } },
+ replace: true
+ });
+ };
+
+ /**
+ * Adds a new screen to the current view to draw components from
+ */
+ const onClickAdd = () => {
+ console.log("clicked add");
+ };
+
+ /**
+ * Saves the current quick screen to local storage
+ */
+ const onClickSave = () => {
+ setStorageModalOpen(true);
+ };
+
+ /**
+ * Loads a quick screen from local storage
+ */
+ const onClickLoad = () => {
+ console.log("clicked load");
+ };
+
+ /**
+ * Loads a quick screen from local storage
+ */
+ const onClickDelete = () => {
+ console.log("clicked delete");
+ };
+
+ const SETTINGS_LIST = [
+ {
+ name: "New",
+ text: "Create new blank Quick Screen",
+ icon: ,
+ onClick: onClickNew
+ },
+ {
+ name: "Add",
+ text: "Add a .bob file to the view",
+ icon: ,
+ onClick: onClickAdd
+ },
+ {
+ name: "Save",
+ text: "Save the current Quick Screen",
+ icon: ,
+ onClick: onClickSave
+ },
+ {
+ name: "Load",
+ text: "Load a Quick Screen",
+ icon: ,
+ onClick: onClickLoad
+ },
+ {
+ name: "Delete",
+ text: "Discard the current Quick Screen",
+ icon: ,
+ onClick: onClickDelete
+ }
+ ];
+
+ return (
+ <>
+
+
+ {SETTINGS_LIST.map(item => (
+
+
+
+
+ {item.icon}
+
+
+
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/src/components/QuickScreens/StorageBrowser.tsx b/src/components/QuickScreens/StorageBrowser.tsx
new file mode 100644
index 0000000..91d67e5
--- /dev/null
+++ b/src/components/QuickScreens/StorageBrowser.tsx
@@ -0,0 +1,251 @@
+import { RichTreeView } from "@mui/x-tree-view/RichTreeView";
+import { useContext, useEffect, useState } from "react";
+import {
+ TreeItem2,
+ TreeItem2Props,
+ TreeViewBaseItem,
+ TreeViewItemId,
+ useTreeItem2
+} from "@mui/x-tree-view";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Stack,
+ TextField
+} from "@mui/material";
+import {
+ useDisplayInstance,
+ useNotification
+} from "@diamondlightsource/cs-web-lib";
+import { StorageContext } from "./Display";
+import { findNodeById, getAllScreensWithChildrenItemIds } from "../utils";
+import FolderIcon from "@mui/icons-material/Folder";
+import FolderOpenIcon from "@mui/icons-material/FolderOpen";
+import SubdirectoryArrowRightIcon from "@mui/icons-material/SubdirectoryArrowRight";
+
+/**
+ * Custom Tree Item that lets us change icon
+ * @param props
+ * @returns
+ */
+function QuickScreenTreeItem(props: TreeItem2Props) {
+ const { status } = useTreeItem2({
+ itemId: props.itemId,
+ children: props.children
+ });
+
+ return (
+
+ status.expandable ? (
+ status.expanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )
+ }}
+ />
+ );
+}
+
+/**
+ * Gets all Quick Screens currently in local storage and
+ * converts them to Tree View Items
+ * @returns
+ */
+export function getQuickScreens(): TreeViewBaseItem[] {
+ const tree: TreeViewBaseItem[] = [];
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)!;
+
+ // Check the item is part of Quick Screens
+ if (!key || !key.startsWith("quickScreens/")) continue;
+ // Fetch content
+ const screenName = key.substring("quickScreens/".length);
+ const parts = screenName.split("/");
+ // Check if screen is in folder
+ let currentLevel = tree;
+ // For each folder, create child file labels/folders
+ for (let j = 0; j < parts.length; j++) {
+ const part = parts[j];
+ const id = parts.slice(0, j + 1).join("/");
+
+ let branch = currentLevel.find(item => item.id === id);
+ const isFolder = j < parts.length - 1;
+
+ // Create new branch
+ if (!branch) {
+ branch = {
+ id,
+ label: part,
+ ...(isFolder ? { children: [] } : {})
+ };
+
+ currentLevel.push(branch);
+ }
+
+ if (branch.children) {
+ currentLevel = branch.children;
+ }
+ }
+ }
+
+ // Recursively sort tree alphabetically
+ const sortTree = (items: TreeViewBaseItem[]) => {
+ items.sort((a, b) => a.label.localeCompare(b.label));
+
+ for (const item of items) {
+ if (item.children) {
+ sortTree(item.children);
+ }
+ }
+ };
+ sortTree(tree);
+ return tree;
+}
+
+export default function LocalStorageBrowser(props: { setModalOpen: any }) {
+ const [quickScreens, setQuickScreens] = useState([]);
+ const [selectedKey, setSelectedKey] = useState("");
+ const [quickScreenName, setQuickScreenName] = useState("");
+ const [selectedIsFolder, setSelectedIsFolder] = useState(false);
+ const [expandedScreens, setExpandedScreens] = useState([]);
+ const [confirmOverwrite, setConfirmOverwrite] = useState(false);
+ const quickScreenStorage = useContext(StorageContext);
+ const fileContent = useDisplayInstance(quickScreenStorage.bobDisplayUuid!);
+ const { showWarning, showError } = useNotification();
+
+ useEffect(() => {
+ const screens = getQuickScreens();
+ setQuickScreens(screens);
+ // Set list of expanded screens
+ getAllScreensWithChildrenItemIds(screens, setExpandedScreens);
+ }, []);
+
+ const handleSelection = (_: any, itemId: any) => {
+ setSelectedKey(itemId);
+ setQuickScreenName(itemId);
+ // Check if the item selected is a folder
+ const selectedNode = findNodeById(quickScreens, itemId);
+ setSelectedIsFolder(!!selectedNode?.children?.length);
+ };
+
+ const handleSave = () => {
+ // If no file name or file content, show notification
+ if (!quickScreenName.trim() || !fileContent) {
+ if (!quickScreenName.trim())
+ showWarning("Unable to save: no Quick Screen name given.");
+ if (!fileContent)
+ showError("Unable to save: no Quick Screen content found.");
+ return;
+ }
+ // Check if user is trying to save as folder and prevent
+ const oldScreen = localStorage.getItem(`quickScreens/${quickScreenName}`);
+ const newScreen = createNewScreen();
+ // Check if content already exists for this name
+ if (oldScreen && oldScreen !== newScreen) {
+ // User confirmation to overwrite
+ setConfirmOverwrite(true);
+ return;
+ }
+ saveQuickScreen();
+ };
+
+ /**
+ * Creates the new Quick Screen instance
+ */
+ const createNewScreen = () =>
+ JSON.stringify({
+ macros: fileContent?.macros ?? {},
+ description: fileContent?.description
+ });
+
+ /**
+ * Save the Quick Screen to local storage
+ */
+ const saveQuickScreen = () => {
+ const newScreen = createNewScreen();
+ localStorage.setItem(`quickScreens/${quickScreenName}`, newScreen);
+ props.setModalOpen(false);
+ };
+
+ return (
+
+
+
+ getAllScreensWithChildrenItemIds(quickScreens, setExpandedScreens)
+ }
+ slots={{ item: QuickScreenTreeItem }}
+ />
+
+
+ {
+ const selectedNode = findNodeById(quickScreens, e.target.value);
+ setSelectedIsFolder(!!selectedNode?.children?.length);
+ setQuickScreenName(e.target.value);
+ }}
+ fullWidth
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/ScreenTreeView.tsx b/src/components/ScreenTreeView.tsx
index fda2b12..e552538 100644
--- a/src/components/ScreenTreeView.tsx
+++ b/src/components/ScreenTreeView.tsx
@@ -6,16 +6,13 @@ import { BeamlineTreeStateContext } from "../App";
import { MenuContext } from "../routes/SynopticPage";
import { executeOpenPageActionWithFileGuid } from "../utils/csWebLibActions";
import { FileIDs } from "../store";
+import { getAllScreensWithChildrenItemIds } from "./utils";
export default function ScreenTreeView() {
const { state } = useContext(BeamlineTreeStateContext);
const { menuOpen } = useContext(MenuContext);
const fileContext = useContext(FileContext);
- const [expandedScreens, setExpandedScreens] = useState([]);
-
- const handleExpandedScreensChange = (screenIds: string[]) => {
- setExpandedScreens(screenIds);
- };
+ const [expandedScreens, setExpandedScreens] = useState([]);
const handleClick = (itemId: string) => {
const selectedBeamlineId = state.currentBeamline;
@@ -41,24 +38,7 @@ export default function ScreenTreeView() {
// When beamline is updated, trigger refresh of expanded screens to fully expand all
useEffect(() => {
- const getAllScreensWithChildrenItemIds = async (
- screenTree: TreeViewBaseItem[]
- ) => {
- const screenIds: TreeViewItemId[] = [];
-
- const registerScreenId = (item: TreeViewBaseItem): void => {
- if (item.children?.length) {
- screenIds.push(item.id);
- (item.children as TreeViewBaseItem[]).forEach(registerScreenId);
- }
- };
-
- for (const screen of screenTree) {
- await registerScreenId(screen);
- }
- setExpandedScreens(screenIds);
- };
- getAllScreensWithChildrenItemIds(currentScreenTree);
+ getAllScreensWithChildrenItemIds(currentScreenTree, setExpandedScreens);
}, [state.currentBeamline, state.beamlines, currentScreenTree]);
return (
@@ -69,7 +49,7 @@ export default function ScreenTreeView() {
selectedItems={state.currentScreenId}
expandedItems={expandedScreens}
onExpandedItemsChange={(_event, itemIds) =>
- handleExpandedScreensChange(itemIds)
+ setExpandedScreens(itemIds)
}
onItemClick={(_event, itemId) => {
if (!itemId || currentFileMetadata[itemId]?.exists === false) {
diff --git a/src/components/utils.ts b/src/components/utils.ts
new file mode 100644
index 0000000..f7a32a9
--- /dev/null
+++ b/src/components/utils.ts
@@ -0,0 +1,50 @@
+import { TreeViewBaseItem, TreeViewItemId } from "@mui/x-tree-view";
+import { Dispatch, SetStateAction } from "react";
+
+/**
+ * Sets the list of all tree items with children that should be expanded
+ * @param screenTree
+ * @param setExpandedScreens
+ */
+export const getAllScreensWithChildrenItemIds = (
+ screenTree: TreeViewBaseItem[],
+ setExpandedScreens: Dispatch>
+) => {
+ const screenIds: TreeViewItemId[] = [];
+
+ const registerScreenId = (item: TreeViewBaseItem): void => {
+ if (item.children?.length) {
+ screenIds.push(item.id);
+ (item.children as TreeViewBaseItem[]).forEach(registerScreenId);
+ }
+ };
+
+ for (const screen of screenTree) {
+ registerScreenId(screen);
+ }
+ setExpandedScreens(screenIds);
+};
+
+/**
+ * Find a node in the treeview when given its id
+ * @param items treeviewitem list
+ * @param id string to match
+ * @returns a TreeViewBaseItem of the match, or undefined if none
+ */
+export const findNodeById = (
+ items: TreeViewBaseItem[],
+ id: string
+): TreeViewBaseItem | undefined => {
+ for (const item of items) {
+ if (item.id === id) {
+ return item;
+ }
+ if (item.children) {
+ const found = findNodeById(item.children, id);
+ if (found) {
+ return found;
+ }
+ }
+ }
+ return undefined;
+};
diff --git a/src/routes/QuickScreensPage.tsx b/src/routes/QuickScreensPage.tsx
index ea6994a..41c445c 100644
--- a/src/routes/QuickScreensPage.tsx
+++ b/src/routes/QuickScreensPage.tsx
@@ -1,6 +1,6 @@
import { Box, CssBaseline } from "@mui/material";
import { useEffect } from "react";
-import QuickScreens from "../components/QuickScreens";
+import QuickScreenDisplay from "../components/QuickScreens/Display";
import DLSAppBar from "../components/AppBar";
/**
@@ -17,7 +17,7 @@ export function QuickScreensPage() {
-
+
>
);
diff --git a/src/tests/components/QuickScreens.test.tsx b/src/tests/components/QuickScreenDisplay.test.tsx
similarity index 95%
rename from src/tests/components/QuickScreens.test.tsx
rename to src/tests/components/QuickScreenDisplay.test.tsx
index b7cd130..7e73c85 100644
--- a/src/tests/components/QuickScreens.test.tsx
+++ b/src/tests/components/QuickScreenDisplay.test.tsx
@@ -1,6 +1,6 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
-import QuickScreens from "../../components/QuickScreens";
+import QuickScreens from "../../components/QuickScreens/Display";
const renderComponent = () => {
return render();
diff --git a/src/tests/components/QuickScreenSettings.test.tsx b/src/tests/components/QuickScreenSettings.test.tsx
index f595189..0f3f93a 100644
--- a/src/tests/components/QuickScreenSettings.test.tsx
+++ b/src/tests/components/QuickScreenSettings.test.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
-import QuickScreenSettings from "../../components/QuickScreenSettings";
+import QuickScreenSettings from "../../components/QuickScreens/Settings";
const renderComponent = () => {
return render();
diff --git a/src/tests/components/QuickScreenStorageBrowser.test.tsx b/src/tests/components/QuickScreenStorageBrowser.test.tsx
new file mode 100644
index 0000000..863a1e9
--- /dev/null
+++ b/src/tests/components/QuickScreenStorageBrowser.test.tsx
@@ -0,0 +1,387 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { StorageContext } from "../../components/QuickScreens/Display";
+import LocalStorageBrowser, {
+ getQuickScreens
+} from "../../components/QuickScreens/StorageBrowser";
+
+const mockSetModalOpen = vi.fn();
+const mockShowWarning = vi.fn();
+const mockShowError = vi.fn();
+
+let mockFileContent: any = {
+ macros: {
+ TEST: "value"
+ },
+ description: "{type: 'display', children: []}"
+};
+
+vi.mock("@diamondlightsource/cs-web-lib", () => ({
+ useDisplayInstance: () => mockFileContent,
+ useNotification: () => ({
+ showWarning: mockShowWarning,
+ showError: mockShowError
+ })
+}));
+
+vi.mock("../utils", () => ({
+ getAllScreensWithChildrenItemIds: vi.fn((_screens, setter) => {
+ setter([]);
+ })
+}));
+
+const renderComponent = () =>
+ render(
+
+
+
+ );
+
+describe("LocalStorageBrowser", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+
+ mockFileContent = {
+ macros: {
+ TEST: "value"
+ },
+ description: "{type: 'display', children: []}"
+ };
+ });
+
+ it("renders the component", () => {
+ const { getByLabelText, getByRole } = renderComponent();
+
+ expect(getByLabelText("Quick Screen Name")).toBeInTheDocument();
+
+ expect(
+ getByRole("button", {
+ name: "Save"
+ })
+ ).toBeDisabled();
+ });
+
+ it("loads Quick Screens from localStorage", async () => {
+ localStorage.setItem(
+ "quickScreens/test screen",
+ JSON.stringify({
+ macros: {},
+ description: "hello"
+ })
+ );
+
+ const { findByText } = renderComponent();
+
+ expect(await findByText("test screen")).toBeInTheDocument();
+ });
+
+ it("allows entering a new quick screen name", async () => {
+ const { getByRole, getByLabelText } = renderComponent();
+
+ const input = getByLabelText("Quick Screen Name");
+ fireEvent.change(input, {
+ target: {
+ value: "new screen"
+ }
+ });
+
+ expect(input).toHaveValue("new screen");
+
+ expect(
+ getByRole("button", {
+ name: "Save"
+ })
+ ).toBeEnabled();
+ });
+
+ it("saves a new quick screen", async () => {
+ const { getByLabelText, getByRole } = renderComponent();
+
+ const input = getByLabelText("Quick Screen Name");
+
+ fireEvent.change(input, {
+ target: {
+ value: "my screen"
+ }
+ });
+
+ fireEvent.click(
+ getByRole("button", {
+ name: "Save"
+ })
+ );
+
+ expect(localStorage.getItem("quickScreens/my screen")).toBe(
+ JSON.stringify({
+ macros: {
+ TEST: "value"
+ },
+ description: "{type: 'display', children: []}"
+ })
+ );
+
+ expect(mockSetModalOpen).toHaveBeenCalledWith(false);
+ });
+
+ it("shows warning when no name is supplied", async () => {
+ const { getByRole, getByLabelText } = renderComponent();
+
+ const button = getByRole("button", {
+ name: "Save"
+ });
+
+ expect(button).toBeDisabled();
+
+ const input = getByLabelText("Quick Screen Name");
+
+ fireEvent.change(input, {
+ target: {
+ value: " "
+ }
+ });
+
+ fireEvent.click(button);
+
+ expect(mockShowWarning).toHaveBeenCalledWith(
+ "Unable to save: no Quick Screen name given."
+ );
+ });
+
+ it("shows error when no file content exists", async () => {
+ mockFileContent = undefined;
+
+ const { getByLabelText, getByRole } = renderComponent();
+
+ fireEvent.change(getByLabelText("Quick Screen Name"), {
+ target: {
+ value: "screen"
+ }
+ });
+
+ await fireEvent.click(
+ getByRole("button", {
+ name: "Save"
+ })
+ );
+
+ expect(mockShowError).toHaveBeenCalledWith(
+ "Unable to save: no Quick Screen content found."
+ );
+ });
+
+ it("opens overwrite dialog when screen already exists", async () => {
+ localStorage.setItem(
+ "quickScreens/existing",
+ JSON.stringify({
+ macros: {}
+ })
+ );
+
+ const { getByLabelText, getByRole, findByText } = renderComponent();
+ fireEvent.change(getByLabelText("Quick Screen Name"), {
+ target: {
+ value: "existing"
+ }
+ });
+
+ await fireEvent.click(
+ getByRole("button", {
+ name: "Save"
+ })
+ );
+
+ expect(await findByText("Overwrite Quick Screen?")).toBeInTheDocument();
+
+ expect(
+ getByRole("button", {
+ name: "Save and overwrite"
+ })
+ ).toBeInTheDocument();
+ });
+
+ it("overwrites an existing screen after confirmation", async () => {
+ localStorage.setItem(
+ "quickScreens/existing",
+ JSON.stringify({
+ macros: {},
+ description: "{type: 'displayGridLayout', children: []}"
+ })
+ );
+
+ const { getByLabelText, getByRole } = renderComponent();
+
+ await fireEvent.change(getByLabelText("Quick Screen Name"), {
+ target: {
+ value: "existing"
+ }
+ });
+
+ await fireEvent.click(
+ await getByRole("button", {
+ name: "Save"
+ })
+ );
+
+ await fireEvent.click(
+ await getByRole("button", {
+ name: "Save and overwrite"
+ })
+ );
+
+ expect(JSON.parse(localStorage.getItem("quickScreens/existing")!)).toEqual({
+ macros: {
+ TEST: "value"
+ },
+ description: "{type: 'display', children: []}"
+ });
+
+ expect(mockSetModalOpen).toHaveBeenCalledWith(false);
+ });
+
+ it("disables save when selecting a folder", async () => {
+ localStorage.setItem("quickScreens/folder/file", "{}");
+
+ const { findByText, getByRole } = renderComponent();
+
+ const folder = await findByText("folder");
+
+ fireEvent.click(folder);
+
+ await waitFor(() => {
+ expect(
+ getByRole("button", {
+ name: "Save"
+ })
+ ).toBeDisabled();
+ });
+ });
+});
+
+describe("getQuickScreens()", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("returns an empty tree when there are no Quick Screens", () => {
+ expect(getQuickScreens()).toEqual([]);
+ });
+
+ it("only uses Quick Screen local storage entries", () => {
+ localStorage.setItem("someOtherKey", "value");
+ localStorage.setItem("quickScreen/settings", "value");
+ expect(getQuickScreens()).toEqual([]);
+ });
+
+ it("creates nested folder structure for path Quick Screen names", () => {
+ localStorage.setItem("quickScreens/folder/testScreen", "{}");
+ expect(getQuickScreens()).toEqual([
+ {
+ id: "folder",
+ label: "folder",
+ children: [
+ {
+ id: "folder/testScreen",
+ label: "testScreen"
+ }
+ ]
+ }
+ ]);
+ });
+
+ it("creates multiple Quick Screens in same folder", () => {
+ localStorage.setItem("quickScreens/folder/screenA", "{}");
+
+ localStorage.setItem("quickScreens/folder/screenB", "{}");
+ expect(getQuickScreens()).toEqual([
+ {
+ id: "folder",
+ label: "folder",
+ children: [
+ {
+ id: "folder/screenA",
+ label: "screenA"
+ },
+ {
+ id: "folder/screenB",
+ label: "screenB"
+ }
+ ]
+ }
+ ]);
+ });
+
+ it("can create nested folders", () => {
+ localStorage.setItem("quickScreens/a/b/c/screen", "{}");
+
+ expect(getQuickScreens()).toEqual([
+ {
+ id: "a",
+ label: "a",
+ children: [
+ {
+ id: "a/b",
+ label: "b",
+ children: [
+ {
+ id: "a/b/c",
+ label: "c",
+ children: [
+ {
+ id: "a/b/c/screen",
+ label: "screen"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]);
+ });
+
+ it("does not create duplicate folders", () => {
+ localStorage.setItem("quickScreens/folder/screen1", "{}");
+
+ localStorage.setItem("quickScreens/folder/screen2", "{}");
+ const result = getQuickScreens();
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("folder");
+ expect(result[0].children).toHaveLength(2);
+ });
+
+ it("sorts items alphabetically", () => {
+ localStorage.setItem("quickScreens/z screen", "{}");
+
+ localStorage.setItem("quickScreens/a screen", "{}");
+
+ localStorage.setItem("quickScreens/m folder/b screen", "{}");
+
+ localStorage.setItem("quickScreens/m folder/a screen", "{}");
+
+ expect(getQuickScreens()).toEqual([
+ {
+ id: "a screen",
+ label: "a screen"
+ },
+ {
+ id: "m folder",
+ label: "m folder",
+ children: [
+ {
+ id: "m folder/a screen",
+ label: "a screen"
+ },
+ {
+ id: "m folder/b screen",
+ label: "b screen"
+ }
+ ]
+ },
+ {
+ id: "z screen",
+ label: "z screen"
+ }
+ ]);
+ });
+});
diff --git a/src/tests/components/utils.test.ts b/src/tests/components/utils.test.ts
new file mode 100644
index 0000000..92ba164
--- /dev/null
+++ b/src/tests/components/utils.test.ts
@@ -0,0 +1,173 @@
+import {
+ findNodeById,
+ getAllScreensWithChildrenItemIds
+} from "../../components/utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { TreeViewBaseItem } from "@mui/x-tree-view";
+
+const setExpandedScreens = vi.fn();
+
+describe("getAllScreensWithChildrenItemIds()", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+ it("sets an empty array when there are no expandable items", () => {
+ const tree: TreeViewBaseItem[] = [
+ {
+ id: "screen 1",
+ label: "Screen 1"
+ },
+ {
+ id: "screen 2",
+ label: "Screen 2"
+ }
+ ];
+
+ getAllScreensWithChildrenItemIds(tree, setExpandedScreens);
+
+ expect(setExpandedScreens).toHaveBeenCalledWith([]);
+ });
+
+ it("returns top-level folders with children", () => {
+ const tree: TreeViewBaseItem[] = [
+ {
+ id: "folder1",
+ label: "Folder 1",
+ children: [
+ {
+ id: "screen1",
+ label: "Screen 1"
+ }
+ ]
+ }
+ ];
+
+ getAllScreensWithChildrenItemIds(tree, setExpandedScreens);
+
+ expect(setExpandedScreens).toHaveBeenCalledWith(["folder1"]);
+ });
+
+ it("loops over nested folders", () => {
+ const tree: TreeViewBaseItem[] = [
+ {
+ id: "folder 1",
+ label: "Folder 1",
+ children: [
+ {
+ id: "folder 1/subfolder",
+ label: "Sub Folder",
+ children: [
+ {
+ id: "folder 1/subfolder/screen",
+ label: "Screen"
+ }
+ ]
+ }
+ ]
+ }
+ ];
+
+ getAllScreensWithChildrenItemIds(tree, setExpandedScreens);
+ expect(setExpandedScreens).toHaveBeenCalledWith([
+ "folder 1",
+ "folder 1/subfolder"
+ ]);
+ });
+
+ it("does not include childless nodes", () => {
+ const tree: TreeViewBaseItem[] = [
+ {
+ id: "folder",
+ label: "Folder",
+ children: [
+ {
+ id: "screen",
+ label: "Screen"
+ }
+ ]
+ }
+ ];
+
+ getAllScreensWithChildrenItemIds(tree, setExpandedScreens);
+ const expandedIds = setExpandedScreens.mock.calls[0][0];
+ expect(expandedIds).not.toContain("screen");
+ expect(expandedIds).toContain("folder");
+ });
+});
+
+describe("findNodeById()", () => {
+ const tree: TreeViewBaseItem[] = [
+ {
+ id: "folder 1",
+ label: "Folder 1",
+ children: [
+ {
+ id: "folder 1/screen 1",
+ label: "Screen 1"
+ },
+ {
+ id: "folder 1/subfolder",
+ label: "Sub Folder",
+ children: [
+ {
+ id: "folder 1/subfolder/screen 2",
+ label: "Screen 2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ id: "screen 3",
+ label: "Screen 3"
+ }
+ ];
+
+ it("can find a top-level node", () => {
+ const result = findNodeById(tree, "screen 3");
+ expect(result).toEqual({
+ id: "screen 3",
+ label: "Screen 3"
+ });
+ });
+
+ it("can find a child node", () => {
+ const result = findNodeById(tree, "folder 1/screen 1");
+ expect(result).toEqual({
+ id: "folder 1/screen 1",
+ label: "Screen 1"
+ });
+ });
+
+ it("finds a deeply nested node", () => {
+ const result = findNodeById(tree, "folder 1/subfolder/screen 2");
+ expect(result).toEqual({
+ id: "folder 1/subfolder/screen 2",
+ label: "Screen 2"
+ });
+ });
+
+ it("returns the folder node when searching for a folder", () => {
+ const result = findNodeById(tree, "folder 1/subfolder");
+ expect(result).toEqual({
+ id: "folder 1/subfolder",
+ label: "Sub Folder",
+ children: [
+ {
+ id: "folder 1/subfolder/screen 2",
+ label: "Screen 2"
+ }
+ ]
+ });
+ });
+
+ it("returns undefined when node doesn't exist", () => {
+ const result = findNodeById(tree, "not here");
+ expect(result).toBeUndefined();
+ });
+
+ it("returns undefined for an empty tree", () => {
+ const result = findNodeById([], "empty");
+ expect(result).toBeUndefined();
+ });
+});