diff --git a/src/components/Menus/Sidebar/Sidebar.jsx b/src/components/Menus/Sidebar/Sidebar.jsx index 994b5ed87..9dcdfde65 100644 --- a/src/components/Menus/Sidebar/Sidebar.jsx +++ b/src/components/Menus/Sidebar/Sidebar.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { useMediaQuery } from "react-responsive"; import FilePanel from "./FilePanel/FilePanel"; @@ -24,9 +24,11 @@ import FileIcon from "../../../utils/FileIcon"; import DownloadPanel from "./DownloadPanel/DownloadPanel"; import InstructionsPanel from "./InstructionsPanel/InstructionsPanel"; import SidebarPanel from "./SidebarPanel"; +import { setSidebarOption } from "../../../redux/EditorSlice"; const Sidebar = ({ options = [], plugins = [], allowMobileView = true }) => { const { t } = useTranslation(); + const dispatch = useDispatch(); const projectType = useSelector((state) => state.editor.project.project_type); const projectImages = useSelector((state) => state.editor.project.image_list); const instructionsSteps = useSelector( @@ -35,6 +37,9 @@ const Sidebar = ({ options = [], plugins = [], allowMobileView = true }) => { const instructionsEditable = useSelector( (state) => state.editor.instructionsEditable, ); + const selectedSidebarOption = useSelector( + (state) => state.editor.selectedSidebarOption, + ); const viewportIsMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); const isMobile = allowMobileView && viewportIsMobile; @@ -134,28 +139,45 @@ const Sidebar = ({ options = [], plugins = [], allowMobileView = true }) => { } const autoOpenPlugin = plugins?.find((plugin) => plugin.autoOpen); - - const [option, setOption] = useState( - autoOpenPlugin - ? autoOpenPlugin.name - : instructionsEditable || instructionsSteps - ? "instructions" - : "file", - ); - const hasInstructions = instructionsSteps && instructionsSteps.length > 0; + let defaultOption = "file"; + if (autoOpenPlugin) { + defaultOption = autoOpenPlugin.name; + } else if (instructionsEditable || hasInstructions) { + defaultOption = "instructions"; + } + const defaultOptionIsAvailable = menuOptions.some( + (menuOption) => menuOption.name === defaultOption, + ); + const nextDefaultOption = defaultOptionIsAvailable ? defaultOption : null; + const initialOption = + selectedSidebarOption === undefined + ? nextDefaultOption + : selectedSidebarOption; + const [option, setOption] = useState(initialOption); + const optionIsAvailable = + option === null || + menuOptions.some((menuOption) => menuOption.name === option); useEffect(() => { - if (!autoOpenPlugin && (instructionsEditable || hasInstructions)) { - setOption("instructions"); + if (selectedSidebarOption === undefined) { + setOption(nextDefaultOption); + } else if (!optionIsAvailable) { + setOption(nextDefaultOption); + dispatch(setSidebarOption(nextDefaultOption)); } - }, [autoOpenPlugin, instructionsEditable, hasInstructions]); + }, [dispatch, nextDefaultOption, optionIsAvailable, selectedSidebarOption]); + + const updateOption = (nextOption) => { + setOption(nextOption); + dispatch(setSidebarOption(nextOption)); + }; const toggleOption = (newOption) => { if (option !== newOption) { - setOption(newOption); + updateOption(newOption); } else if (!isMobile) { - setOption(null); + updateOption(null); } }; diff --git a/src/components/Menus/Sidebar/Sidebar.test.js b/src/components/Menus/Sidebar/Sidebar.test.js index 5e28ab817..7b26990d0 100644 --- a/src/components/Menus/Sidebar/Sidebar.test.js +++ b/src/components/Menus/Sidebar/Sidebar.test.js @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import Sidebar from "./Sidebar"; import configureStore from "redux-mock-store"; import { Provider } from "react-redux"; +import { setSidebarOption } from "../../../redux/EditorSlice"; let images = [ { @@ -24,10 +25,27 @@ const optionsWithDownload = [ "info", ]; +const renderSidebarWithState = (initialState, props = {}) => { + const mockStore = configureStore([]); + const store = mockStore(initialState); + + return { + store, + ...render( + +
+ +
+
, + ), + }; +}; + describe("When project has images", () => { describe("and no instructions", () => { + let store; + beforeEach(() => { - const mockStore = configureStore([]); const initialState = { editor: { project: { @@ -37,14 +55,7 @@ describe("When project has images", () => { }, instructions: {}, }; - const store = mockStore(initialState); - render( - -
- -
-
, - ); + ({ store } = renderSidebarWithState(initialState)); }); test("Clicking expand opens the file pane", () => { @@ -104,6 +115,7 @@ describe("When project has images", () => { const imageButton = screen.getByTitle("sidebar.images"); fireEvent.click(imageButton); expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + expect(store.getActions()).toEqual([setSidebarOption("images")]); }); }); @@ -324,6 +336,86 @@ describe("When the project has no instructions", () => { }); }); +describe("When sidebar state is persisted", () => { + test("Renders the stored sidebar state", () => { + const initialState = { + editor: { + project: { + components: [], + image_list: images, + }, + selectedSidebarOption: "images", + }, + instructions: {}, + }; + const { unmount } = renderSidebarWithState(initialState); + + expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + + unmount(); + + renderSidebarWithState({ + editor: { + project: { + components: [], + image_list: images, + }, + selectedSidebarOption: null, + }, + instructions: {}, + }); + + expect(screen.queryByTitle("sidebar.expand")).toBeInTheDocument(); + expect(screen.queryByText("filePanel.files")).not.toBeInTheDocument(); + }); +}); + +describe("When no sidebar option is stored", () => { + const mockStore = configureStore([]); + + test("Switches to a new default option after mount", () => { + const initialState = { + editor: { + project: { + components: [], + image_list: [], + }, + instructionsEditable: false, + }, + instructions: {}, + }; + const nextState = { + editor: { + project: { + components: [], + image_list: [], + }, + instructionsEditable: true, + }, + instructions: {}, + }; + + const { rerender } = renderSidebarWithState(initialState, { + options: ["file", "instructions"], + }); + + expect(screen.queryByText("filePanel.files")).toBeInTheDocument(); + + rerender( + +
+ +
+
, + ); + + expect( + screen.queryByText("instructionsPanel.projectSteps"), + ).toBeInTheDocument(); + expect(screen.queryByText("filePanel.files")).not.toBeInTheDocument(); + }); +}); + describe("When plugins are provided", () => { const initialState = { editor: { diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index 3c7800a58..a673d6255 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -23,6 +23,7 @@ let store; const renderWebComponentProject = ({ projectType, instructions, + imageList = [], permitOverride = true, loading, codeRunTriggered = false, @@ -38,7 +39,7 @@ const renderWebComponentProject = ({ components: [ { name: "main", extension: "py", content: "print('hello')" }, ], - image_list: [], + image_list: imageList, instructions, }, loading, @@ -55,7 +56,7 @@ const renderWebComponentProject = ({ }; store = mockStore(initialState); - render( + return render( , diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index c3ac922cd..096189265 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -119,6 +119,7 @@ export const editorInitialState = { newFileModalShowing: false, renameFileModalShowing: false, sidebarShowing: true, + selectedSidebarOption: undefined, modals: {}, errorDetails: {}, runnerBeingLoaded: null | "pyodide" | "skulpt", @@ -374,6 +375,9 @@ export const EditorSlice = createSlice({ hideSidebar: (state) => { state.sidebarShowing = false; }, + setSidebarOption: (state, action) => { + state.selectedSidebarOption = action.payload; + }, disableTheming: (state) => { state.isThemeable = false; }, @@ -495,6 +499,7 @@ export const { closeRenameFileModal, showSidebar, hideSidebar, + setSidebarOption, disableTheming, setErrorDetails, } = EditorSlice.actions;