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} + + + + + + ))} + + + + + Quick Screen Browser + + + + + + + + + ({ + position: "absolute", + right: 8, + top: 8, + color: theme.palette.primary.main + })} + > + + + + + ); +} 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 + /> + + + setConfirmOverwrite(false)} + > + Overwrite Quick Screen? + + + A Quick Screen named {quickScreenName} already + exists. Saving will replace the existing content. Are you sure you + want to continue? + + + + + + + + + ); +} 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(); + }); +});