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", () => {