From 5275f2022611a364bf3b080a745a32e8ffc6d932 Mon Sep 17 00:00:00 2001 From: abcampo-iry <261805581+abcampo-iry@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:58:33 +0100 Subject: [PATCH 1/2] Externalize selected value in redux - This we way avoid that when it changes between mobile and desktop it looses previous state --- src/components/Menus/Sidebar/Sidebar.jsx | 46 +++++-- .../MobileProject/MobileProject.test.js | 38 ------ .../WebComponentProject.test.js | 122 +++++++++++++++++- src/redux/EditorSlice.js | 5 + 4 files changed, 156 insertions(+), 55 deletions(-) diff --git a/src/components/Menus/Sidebar/Sidebar.jsx b/src/components/Menus/Sidebar/Sidebar.jsx index 994b5ed87..26d407f7c 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,41 @@ const Sidebar = ({ options = [], plugins = [], allowMobileView = true }) => { } const autoOpenPlugin = plugins?.find((plugin) => plugin.autoOpen); - + 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 [option, setOption] = useState( - autoOpenPlugin - ? autoOpenPlugin.name - : instructionsEditable || instructionsSteps - ? "instructions" - : "file", + selectedSidebarOption ?? nextDefaultOption, ); - const hasInstructions = instructionsSteps && instructionsSteps.length > 0; - useEffect(() => { - if (!autoOpenPlugin && (instructionsEditable || hasInstructions)) { - setOption("instructions"); + if ( + option !== null && + !menuOptions.some((menuOption) => menuOption.name === option) + ) { + setOption(nextDefaultOption); + dispatch(setSidebarOption(nextDefaultOption)); } - }, [autoOpenPlugin, instructionsEditable, hasInstructions]); + }, [dispatch, menuOptions, nextDefaultOption, option]); + + 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/Mobile/MobileProject/MobileProject.test.js b/src/components/Mobile/MobileProject/MobileProject.test.js index 7893a8ecf..3aeded1f2 100644 --- a/src/components/Mobile/MobileProject/MobileProject.test.js +++ b/src/components/Mobile/MobileProject/MobileProject.test.js @@ -124,10 +124,6 @@ describe("When withSidebar is true", () => { ); }); - test("renders the sidebar open button", () => { - expect(screen.getByText("mobile.menu")).toBeInTheDocument(); - }); - test("clicking sidebar open button dispatches action to open the sidebar", () => { const sidebarOpenButton = screen.getByText("mobile.menu"); fireEvent.click(sidebarOpenButton); @@ -135,40 +131,6 @@ describe("When withSidebar is true", () => { }); }); -describe("When sidebar is open", () => { - beforeEach(() => { - const initialState = { - editor: { - project: { - components: [ - { - name: "main", - extension: "py", - content: "print('hello')", - }, - ], - image_list: [], - }, - openFiles: [], - focussedFileIndices: [], - sidebarShowing: true, - }, - auth: {}, - instructions: {}, - }; - const store = mockStore(initialState); - render( - - - , - ); - }); - - test("Sidebar renders with the correct options", () => { - expect(screen.queryByTitle("sidebar.settings")).toBeInTheDocument(); - }); -}); - describe("When withSidebar is false", () => { beforeEach(() => { const initialState = { diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index 3c7800a58..eb484e4b6 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -1,8 +1,26 @@ 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 from "../../redux/EditorSlice"; +import InstructionsReducer from "../../redux/InstructionsSlice"; +import AuthReducer 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 +41,7 @@ let store; const renderWebComponentProject = ({ projectType, instructions, + imageList = [], permitOverride = true, loading, codeRunTriggered = false, @@ -38,7 +57,7 @@ const renderWebComponentProject = ({ components: [ { name: "main", extension: "py", content: "print('hello')" }, ], - image_list: [], + image_list: imageList, instructions, }, loading, @@ -55,7 +74,50 @@ const renderWebComponentProject = ({ }; store = mockStore(initialState); - render( + return render( + + + , + ); +}; + +const renderWebComponentProjectWithRealStore = ({ + projectType, + instructions, + imageList = [], + permitOverride = true, + loading, + props = {}, +}) => { + const rootReducer = combineReducers({ + editor: EditorReducer, + auth: AuthReducer, + instructions: InstructionsReducer, + }); + const preloadedState = { + editor: { + project: { + project_type: projectType, + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: imageList, + instructions, + }, + loading, + openFiles: [], + focussedFileIndices: [], + }, + instructions: { + currentStepPosition: 3, + permitOverride, + }, + auth: {}, + }; + + store = configureRealStore({ reducer: rootReducer, preloadedState }); + + return render( , @@ -254,6 +316,9 @@ describe("When code run finishes", () => { describe("When withSidebar is true", () => { beforeEach(() => { + setMedia({ + width: "1000px", + }); renderWebComponentProject({ props: { withSidebar: true, sidebarOptions: ["settings"] }, }); @@ -268,6 +333,57 @@ 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(); + }); +}); + 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; From 51a282b92070c75c2c540f9a420d9ba51eb75daa Mon Sep 17 00:00:00 2001 From: abcampo-iry <261805581+abcampo-iry@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:47:23 +0100 Subject: [PATCH 2/2] address copilot errors --- src/components/Menus/Sidebar/Sidebar.jsx | 18 +++++---- .../MobileProject/MobileProject.test.js | 38 +++++++++++++++++++ .../WebComponentProject.test.js | 31 +++++++++++++-- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/components/Menus/Sidebar/Sidebar.jsx b/src/components/Menus/Sidebar/Sidebar.jsx index 26d407f7c..d4a31f441 100644 --- a/src/components/Menus/Sidebar/Sidebar.jsx +++ b/src/components/Menus/Sidebar/Sidebar.jsx @@ -150,19 +150,21 @@ const Sidebar = ({ options = [], plugins = [], allowMobileView = true }) => { (menuOption) => menuOption.name === defaultOption, ); const nextDefaultOption = defaultOptionIsAvailable ? defaultOption : null; - const [option, setOption] = useState( - selectedSidebarOption ?? nextDefaultOption, - ); + const initialOption = + selectedSidebarOption === undefined + ? nextDefaultOption + : selectedSidebarOption; + const [option, setOption] = useState(initialOption); + const optionIsAvailable = + option === null || + menuOptions.some((menuOption) => menuOption.name === option); useEffect(() => { - if ( - option !== null && - !menuOptions.some((menuOption) => menuOption.name === option) - ) { + if (!optionIsAvailable) { setOption(nextDefaultOption); dispatch(setSidebarOption(nextDefaultOption)); } - }, [dispatch, menuOptions, nextDefaultOption, option]); + }, [dispatch, nextDefaultOption, optionIsAvailable]); const updateOption = (nextOption) => { setOption(nextOption); diff --git a/src/components/Mobile/MobileProject/MobileProject.test.js b/src/components/Mobile/MobileProject/MobileProject.test.js index 3aeded1f2..7893a8ecf 100644 --- a/src/components/Mobile/MobileProject/MobileProject.test.js +++ b/src/components/Mobile/MobileProject/MobileProject.test.js @@ -124,6 +124,10 @@ describe("When withSidebar is true", () => { ); }); + test("renders the sidebar open button", () => { + expect(screen.getByText("mobile.menu")).toBeInTheDocument(); + }); + test("clicking sidebar open button dispatches action to open the sidebar", () => { const sidebarOpenButton = screen.getByText("mobile.menu"); fireEvent.click(sidebarOpenButton); @@ -131,6 +135,40 @@ describe("When withSidebar is true", () => { }); }); +describe("When sidebar is open", () => { + beforeEach(() => { + const initialState = { + editor: { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello')", + }, + ], + image_list: [], + }, + openFiles: [], + focussedFileIndices: [], + sidebarShowing: true, + }, + auth: {}, + instructions: {}, + }; + const store = mockStore(initialState); + render( + + + , + ); + }); + + test("Sidebar renders with the correct options", () => { + expect(screen.queryByTitle("sidebar.settings")).toBeInTheDocument(); + }); +}); + describe("When withSidebar is false", () => { beforeEach(() => { const initialState = { diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index eb484e4b6..9d2ed6af3 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -9,9 +9,13 @@ import { import { matchMedia, setMedia } from "mock-match-media"; import WebComponentProject from "./WebComponentProject"; import { MOBILE_BREAKPOINT } from "../../utils/mediaQueryBreakpoints"; -import EditorReducer from "../../redux/EditorSlice"; -import InstructionsReducer from "../../redux/InstructionsSlice"; -import AuthReducer from "../../redux/WebComponentAuthSlice"; +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; @@ -88,6 +92,7 @@ const renderWebComponentProjectWithRealStore = ({ permitOverride = true, loading, props = {}, + editorOverrides = {}, }) => { const rootReducer = combineReducers({ editor: EditorReducer, @@ -96,7 +101,10 @@ const renderWebComponentProjectWithRealStore = ({ }); const preloadedState = { editor: { + ...editorInitialState, + ...editorOverrides, project: { + ...editorInitialState.project, project_type: projectType, components: [ { name: "main", extension: "py", content: "print('hello')" }, @@ -109,10 +117,11 @@ const renderWebComponentProjectWithRealStore = ({ focussedFileIndices: [], }, instructions: { + ...instructionsInitialState, currentStepPosition: 3, permitOverride, }, - auth: {}, + auth: authInitialState, }; store = configureRealStore({ reducer: rootReducer, preloadedState }); @@ -382,6 +391,20 @@ describe("When resizing across the mobile breakpoint", () => { 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", () => {