diff --git a/src/components/Menus/Sidebar/Sidebar.jsx b/src/components/Menus/Sidebar/Sidebar.jsx index 994b5ed87..d4a31f441 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,43 @@ 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 (!optionIsAvailable) { + setOption(nextDefaultOption); + dispatch(setSidebarOption(nextDefaultOption)); } - }, [autoOpenPlugin, instructionsEditable, hasInstructions]); + }, [dispatch, nextDefaultOption, optionIsAvailable]); + + 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/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index 3c7800a58..9d2ed6af3 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -1,8 +1,30 @@ import React from "react"; -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; +import { + combineReducers, + configureStore as configureRealStore, +} from "@reduxjs/toolkit"; +import { matchMedia, setMedia } from "mock-match-media"; import WebComponentProject from "./WebComponentProject"; +import { MOBILE_BREAKPOINT } from "../../utils/mediaQueryBreakpoints"; +import EditorReducer, { editorInitialState } from "../../redux/EditorSlice"; +import InstructionsReducer, { + instructionsInitialState, +} from "../../redux/InstructionsSlice"; +import AuthReducer, { + authInitialState, +} from "../../redux/WebComponentAuthSlice"; + +let mockMediaQuery = (query) => { + return matchMedia(query).matches; +}; + +jest.mock("react-responsive", () => ({ + ...jest.requireActual("react-responsive"), + useMediaQuery: ({ query }) => mockMediaQuery(query), +})); const codeChangedHandler = jest.fn(); const runStartedHandler = jest.fn(); @@ -23,6 +45,7 @@ let store; const renderWebComponentProject = ({ projectType, instructions, + imageList = [], permitOverride = true, loading, codeRunTriggered = false, @@ -38,7 +61,7 @@ const renderWebComponentProject = ({ components: [ { name: "main", extension: "py", content: "print('hello')" }, ], - image_list: [], + image_list: imageList, instructions, }, loading, @@ -55,7 +78,55 @@ const renderWebComponentProject = ({ }; store = mockStore(initialState); - render( + return render( + + + , + ); +}; + +const renderWebComponentProjectWithRealStore = ({ + projectType, + instructions, + imageList = [], + permitOverride = true, + loading, + props = {}, + editorOverrides = {}, +}) => { + const rootReducer = combineReducers({ + editor: EditorReducer, + auth: AuthReducer, + instructions: InstructionsReducer, + }); + const preloadedState = { + editor: { + ...editorInitialState, + ...editorOverrides, + project: { + ...editorInitialState.project, + project_type: projectType, + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: imageList, + instructions, + }, + loading, + openFiles: [], + focussedFileIndices: [], + }, + instructions: { + ...instructionsInitialState, + currentStepPosition: 3, + permitOverride, + }, + auth: authInitialState, + }; + + store = configureRealStore({ reducer: rootReducer, preloadedState }); + + return render( , @@ -254,6 +325,9 @@ describe("When code run finishes", () => { describe("When withSidebar is true", () => { beforeEach(() => { + setMedia({ + width: "1000px", + }); renderWebComponentProject({ props: { withSidebar: true, sidebarOptions: ["settings"] }, }); @@ -268,6 +342,71 @@ describe("When withSidebar is true", () => { }); }); +describe("When resizing across the mobile breakpoint", () => { + test("Keeps the selected sidebar panel after switching to mobile and back", () => { + setMedia({ + width: "1000px", + }); + + const view = renderWebComponentProjectWithRealStore({ + imageList: [{ filename: "earth.png", url: "/earth.png" }], + props: { withSidebar: true, sidebarOptions: ["file", "images"] }, + }); + + fireEvent.click(screen.getByTitle("sidebar.images")); + expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + + act(() => { + setMedia({ + width: MOBILE_BREAKPOINT, + }); + }); + + view.rerender( + + + , + ); + + fireEvent.click(screen.getByText("mobile.menu")); + expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + + act(() => { + setMedia({ + width: "1000px", + }); + }); + + view.rerender( + + + , + ); + + expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + }); + + test("Keeps the sidebar collapsed when persisted as null", () => { + setMedia({ + width: "1000px", + }); + + renderWebComponentProjectWithRealStore({ + props: { withSidebar: true, sidebarOptions: ["file", "images"] }, + editorOverrides: { selectedSidebarOption: null }, + }); + + expect(screen.queryByTitle("sidebar.expand")).toBeInTheDocument(); + expect(screen.queryByText("filePanel.files")).not.toBeInTheDocument(); + }); +}); + describe("When withProjectbar is true", () => { beforeEach(() => { renderWebComponentProject({ diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index c4c1a3190..e3d5328b7 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", @@ -362,6 +363,9 @@ export const EditorSlice = createSlice({ hideSidebar: (state) => { state.sidebarShowing = false; }, + setSidebarOption: (state, action) => { + state.selectedSidebarOption = action.payload; + }, disableTheming: (state) => { state.isThemeable = false; }, @@ -482,6 +486,7 @@ export const { closeRenameFileModal, showSidebar, hideSidebar, + setSidebarOption, disableTheming, setErrorDetails, } = EditorSlice.actions;