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;