From a316165ac69e0b5156a31fb46fae1b5732a058de Mon Sep 17 00:00:00 2001 From: abcampo-iry <261805581+abcampo-iry@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:14:37 +0100 Subject: [PATCH] Persist sidebar state Store the selected sidebar option in Redux so desktop and mobile share the same panel state. Preserve collapsed state and keep the default panel in sync when instructions appear after mount. --- src/components/Menus/Sidebar/Sidebar.jsx | 52 ++++++--- src/components/Menus/Sidebar/Sidebar.test.js | 110 ++++++++++++++++-- .../WebComponentProject.test.js | 5 +- src/redux/EditorSlice.js | 5 + 4 files changed, 146 insertions(+), 26 deletions(-) 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;