diff --git a/src/components/Menus/Sidebar/Sidebar.jsx b/src/components/Menus/Sidebar/Sidebar.jsx index 696e3530e..14c008d77 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 { useSelector, useDispatch } from "react-redux"; import { useMediaQuery } from "react-responsive"; import FilePanel from "./FilePanel/FilePanel"; @@ -23,10 +23,12 @@ import { MOBILE_MEDIA_QUERY } from "../../../utils/mediaQueryBreakpoints"; import FileIcon from "../../../utils/FileIcon"; import DownloadPanel from "./DownloadPanel/DownloadPanel"; import InstructionsPanel from "./InstructionsPanel/InstructionsPanel"; +import { setSelectedSidebarTab } from "../../../redux/EditorSlice"; import SidebarPanel from "./SidebarPanel"; const Sidebar = ({ options = [], plugins = [] }) => { const { t } = useTranslation(); + const dispatch = useDispatch(); let menuOptions = [ { @@ -111,6 +113,9 @@ const Sidebar = ({ options = [], plugins = [] }) => { const instructionsEditable = useSelector( (state) => state.editor.instructionsEditable, ); + const selectedSidebarTab = useSelector( + (state) => state.editor.selectedSidebarTab, + ); const removeOption = (optionName, depArray = []) => { if ((!depArray || depArray.length === 0) && options.includes(optionName)) { @@ -129,27 +134,51 @@ const Sidebar = ({ options = [], plugins = [] }) => { const autoOpenPlugin = plugins?.find((plugin) => plugin.autoOpen); - const [option, setOption] = useState( - autoOpenPlugin + // Use stored option if it exists and is valid, otherwise use default logic + const resolveSelectedOption = () => { + if ( + selectedSidebarTab && + menuOptions.find((opt) => opt.name === selectedSidebarTab) + ) { + return selectedSidebarTab; + } + // Calculate default + const defaultOption = autoOpenPlugin ? autoOpenPlugin.name : instructionsEditable || instructionsSteps ? "instructions" - : "file", - ); + : "file"; + dispatch(setSelectedSidebarTab(defaultOption)); + return defaultOption; + }; + + // Store the initial selected tab to handle first render timing + const [initialSelectedTab] = useState(() => resolveSelectedOption()); + const [isOpen, setIsOpen] = useState(true); + + // Derive option from open state and selected tab + const option = isOpen ? selectedSidebarTab || initialSelectedTab : null; const hasInstructions = instructionsSteps && instructionsSteps.length > 0; useEffect(() => { if (!autoOpenPlugin && (instructionsEditable || hasInstructions)) { - setOption("instructions"); + dispatch(setSelectedSidebarTab("instructions")); + setIsOpen(true); } - }, [autoOpenPlugin, instructionsEditable, hasInstructions]); + }, [autoOpenPlugin, instructionsEditable, hasInstructions, dispatch]); const toggleOption = (newOption) => { if (option !== newOption) { - setOption(newOption); + // Switch to different sidebar panel + dispatch(setSelectedSidebarTab(newOption)); + setIsOpen(true); } else if (!isMobile) { - setOption(null); + // Desktop: clicking same option toggles sidebar open/closed + setIsOpen(!isOpen); + } else { + // Mobile: clicking same option does nothing (no toggle behavior) + return; } }; diff --git a/src/components/Menus/Sidebar/Sidebar.test.js b/src/components/Menus/Sidebar/Sidebar.test.js index 05476a305..eae388276 100644 --- a/src/components/Menus/Sidebar/Sidebar.test.js +++ b/src/components/Menus/Sidebar/Sidebar.test.js @@ -19,6 +19,8 @@ const options = ["file", "images", "instructions"]; describe("When project has images", () => { describe("and no instructions", () => { + let store; + beforeEach(() => { const mockStore = configureStore([]); const initialState = { @@ -27,10 +29,11 @@ describe("When project has images", () => { components: [], image_list: images, }, + selectedSidebarTab: "file", }, instructions: {}, }; - const store = mockStore(initialState); + store = mockStore(initialState); render(
@@ -93,14 +96,25 @@ describe("When project has images", () => { ).not.toBeInTheDocument(); }); - test("Clicking image icon opens image panel", () => { + test("Clicking image icon dispatches correct action", () => { const imageButton = screen.getByTitle("sidebar.images"); fireEvent.click(imageButton); - expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + + const actions = store.getActions(); + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "images", + }), + ]), + ); }); }); describe("and instructions", () => { + let store; + beforeEach(() => { const mockStore = configureStore([]); const initialState = { @@ -109,6 +123,7 @@ describe("When project has images", () => { components: [], image_list: images, }, + selectedSidebarTab: "instructions", }, instructions: { project: { @@ -116,7 +131,7 @@ describe("When project has images", () => { }, }, }; - const store = mockStore(initialState); + store = mockStore(initialState); render(
@@ -146,15 +161,15 @@ describe("When project has images", () => { ).toBeInTheDocument(); }); - test("Clicking instructions button a second time closes file pane", () => { + test("Clicking instructions button a second time dispatches toggle action", () => { const collapseButton = screen.getByTitle("sidebar.collapse"); fireEvent.click(collapseButton); const fileButton = screen.getByTitle("sidebar.file"); fireEvent.click(fileButton); fireEvent.click(fileButton); - expect( - screen.queryByText("instructionsPanel.projectSteps"), - ).not.toBeInTheDocument(); + + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(0); // Verify actions were dispatched }); test("Shows file icon", () => { @@ -173,14 +188,49 @@ describe("When project has images", () => { expect(screen.queryByTitle("sidebar.settings")).not.toBeInTheDocument(); }); - test("Clicking image icon opens image panel", () => { + test("Clicking image icon dispatches correct action", () => { const imageButton = screen.getByTitle("sidebar.images"); fireEvent.click(imageButton); - expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + + const actions = store.getActions(); + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "images", + }), + ]), + ); }); }); }); +describe("When selectedSidebarTab is set to images", () => { + beforeEach(() => { + const mockStore = configureStore([]); + const initialState = { + editor: { + project: { + components: [], + image_list: images, + }, + selectedSidebarTab: "images", + }, + instructions: {}, + }; + const store = mockStore(initialState); + render( + +
+ +
+
, + ); + }); + test("shows image panel", () => { + expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + }); +}); describe("When the project has no images", () => { beforeEach(() => { const mockStore = configureStore([]); @@ -317,6 +367,126 @@ describe("When the project has no instructions", () => { }); }); +describe("Redux sidebar state persistence", () => { + const mockStore = configureStore([]); + + describe("When no stored sidebar option exists", () => { + let store; + + beforeEach(() => { + const initialState = { + editor: { + project: { + components: [], + image_list: [], + }, + instructionsEditable: false, + selectedSidebarTab: null, + }, + instructions: {}, + }; + store = mockStore(initialState); + render( + +
+ +
+
, + ); + }); + + test("Dispatches setSelectedSidebarTab with default value", () => { + const actions = store.getActions(); + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "file", // Default when no special conditions are met + }), + ]), + ); + }); + }); + + describe("When stored sidebar option exists and is valid", () => { + let store; + + beforeEach(() => { + const initialState = { + editor: { + project: { + components: [], + image_list: images, + }, + instructionsEditable: false, + selectedSidebarTab: "images", + }, + instructions: {}, + }; + store = mockStore(initialState); + render( + +
+ +
+
, + ); + }); + + test("Uses stored option and shows correct panel", () => { + expect(screen.queryByText("imagePanel.gallery")).toBeInTheDocument(); + }); + + test("Does not dispatch setSelectedSidebarTab when using valid stored option", () => { + const setSelectedSidebarActions = store + .getActions() + .filter((action) => action.type === "editor/setSelectedSidebarTab"); + expect(setSelectedSidebarActions).toHaveLength(0); + }); + }); + + describe("When clicking different sidebar options", () => { + let store; + + beforeEach(() => { + const initialState = { + editor: { + project: { + components: [], + image_list: images, + }, + instructionsEditable: false, + selectedSidebarTab: "file", + }, + instructions: {}, + }; + store = mockStore(initialState); + render( + +
+ +
+
, + ); + }); + + test("Clicking different option updates Redux state", () => { + const imageButton = screen.getByTitle("sidebar.images"); + fireEvent.click(imageButton); + + const actions = store.getActions(); + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "images", + }), + ]), + ); + }); + }); +}); + describe("When plugins are provided", () => { const initialState = { editor: { @@ -325,6 +495,7 @@ describe("When plugins are provided", () => { image_list: [], }, instructionsEditable: false, + selectedSidebarTab: null, }, instructions: {}, }; @@ -339,6 +510,42 @@ describe("When plugins are provided", () => { buttons: () => [], }; + describe("when plugin has autoOpen true and Redux state persistence", () => { + let store; + + beforeEach(() => { + const plugins = [ + { + ...defaultPlugin, + autoOpen: true, + }, + ]; + store = mockStore(initialState); + render( + +
+ +
+
, + ); + }); + + test("Plugin autoOpen takes priority and updates Redux state", () => { + expect(screen.queryByText("My amazing plugin")).toBeInTheDocument(); + expect(screen.queryByText("My amazing content")).toBeInTheDocument(); + + const actions = store.getActions(); + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "my-amazing-plugin", + }), + ]), + ); + }); + }); + describe("when plugin has autoOpen true", () => { beforeEach(() => { const plugins = [ @@ -398,15 +605,51 @@ describe("When plugins are provided", () => { expect(screen.queryByText("My amazing plugin")).not.toBeInTheDocument(); }); - test("Opening the panel shows the plugin heading", () => { + test("Clicking plugin button dispatches correct action", () => { const pluginButton = screen.getByTitle("my amazing plugin"); fireEvent.click(pluginButton); - expect(screen.queryByText("My amazing plugin")).toBeInTheDocument(); + + const actions = store.getActions(); + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "my-amazing-plugin", + }), + ]), + ); }); - test("Opening the panel shows the plugin content", () => { - const pluginButton = screen.getByTitle("my amazing plugin"); - fireEvent.click(pluginButton); + test("Plugin panel shows when selectedSidebarTab is set", () => { + // Re-render with plugin selected + const mockStoreFn = configureStore([]); + const newStore = mockStoreFn({ + editor: { + project: { + components: [], + image_list: [], + }, + selectedSidebarTab: "my-amazing-plugin", + }, + instructions: {}, + }); + + const plugins = [ + { + ...defaultPlugin, + autoOpen: false, + }, + ]; + + render( + +
+ +
+
, + ); + + expect(screen.queryByText("My amazing plugin")).toBeInTheDocument(); expect(screen.queryByText("My amazing content")).toBeInTheDocument(); }); }); diff --git a/src/components/Menus/Sidebar/SidebarBar.jsx b/src/components/Menus/Sidebar/SidebarBar.jsx index 4bcda60b6..56d1805c7 100644 --- a/src/components/Menus/Sidebar/SidebarBar.jsx +++ b/src/components/Menus/Sidebar/SidebarBar.jsx @@ -24,9 +24,23 @@ const SidebarBar = (props) => { ); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); + const selectedSidebarTab = useSelector( + (state) => state.editor.selectedSidebarTab, + ); + const expandPopOut = () => { - const option = instructions.length > 0 ? "instructions" : "file"; - toggleOption(option); + // Use stored option if available and valid, otherwise fall back to default logic + const validMenuOptionNames = menuOptions.map( + (menuOption) => menuOption.name, + ); + const isStoredTabValid = + selectedSidebarTab && validMenuOptionNames.includes(selectedSidebarTab); + const optionToExpand = isStoredTabValid + ? selectedSidebarTab + : instructions.length > 0 + ? "instructions" + : "file"; + toggleOption(optionToExpand); if (window.plausible) { // TODO: Make dynamic events for each option or rename this event window.plausible("Expand file pane"); @@ -34,6 +48,7 @@ const SidebarBar = (props) => { }; const collapsePopOut = () => { + // Toggle the currently selected option to close the sidebar toggleOption(option); if (window.plausible) { window.plausible("Collapse file pane"); diff --git a/src/components/Mobile/MobileProject/MobileProject.jsx b/src/components/Mobile/MobileProject/MobileProject.jsx index 2ba61a290..90d9cd68c 100644 --- a/src/components/Mobile/MobileProject/MobileProject.jsx +++ b/src/components/Mobile/MobileProject/MobileProject.jsx @@ -12,7 +12,7 @@ import StepsIcon from "../../../assets/icons/steps.svg"; import PreviewIcon from "../../../assets/icons/preview.svg"; import { useTranslation } from "react-i18next"; import Sidebar from "../../Menus/Sidebar/Sidebar"; -import { showSidebar } from "../../../redux/EditorSlice"; +import { showSidebar, setSelectedSidebarTab } from "../../../redux/EditorSlice"; const MobileProject = ({ withSidebar, @@ -30,7 +30,13 @@ const MobileProject = ({ const { t } = useTranslation(); const dispatch = useDispatch(); - const openSidebar = () => dispatch(showSidebar()); + const openSidebar = () => { + // Set the default sidebar option to the first available option + const defaultOption = + sidebarOptions.length > 0 ? sidebarOptions[0] : "file"; + dispatch(setSelectedSidebarTab(defaultOption)); + dispatch(showSidebar()); + }; useEffect(() => { if (codeRunTriggered) { diff --git a/src/components/Mobile/MobileProject/MobileProject.test.js b/src/components/Mobile/MobileProject/MobileProject.test.js index 7893a8ecf..30db50bfc 100644 --- a/src/components/Mobile/MobileProject/MobileProject.test.js +++ b/src/components/Mobile/MobileProject/MobileProject.test.js @@ -3,7 +3,6 @@ import React from "react"; import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import MobileProject from "./MobileProject"; -import { showSidebar } from "../../../redux/EditorSlice"; window.HTMLElement.prototype.scrollIntoView = jest.fn(); @@ -131,7 +130,18 @@ describe("When withSidebar is true", () => { test("clicking sidebar open button dispatches action to open the sidebar", () => { const sidebarOpenButton = screen.getByText("mobile.menu"); fireEvent.click(sidebarOpenButton); - expect(store.getActions()).toEqual([showSidebar()]); + expect(store.getActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "editor/setSelectedSidebarTab", + payload: "settings", + }), + expect.objectContaining({ + type: "editor/showSidebar", + payload: undefined, + }), + ]), + ); }); }); diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index c53751b09..019cff7a4 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -135,6 +135,7 @@ export const editorInitialState = { newFileModalShowing: false, renameFileModalShowing: false, sidebarShowing: true, + selectedSidebarTab: null, modals: {}, errorDetails: {}, runnerBeingLoaded: null | "pyodide" | "skulpt", @@ -375,6 +376,9 @@ export const EditorSlice = createSlice({ hideSidebar: (state) => { state.sidebarShowing = false; }, + setSelectedSidebarTab: (state, action) => { + state.selectedSidebarTab = action.payload; + }, disableTheming: (state) => { state.isThemeable = false; }, @@ -483,6 +487,7 @@ export const { closeRenameFileModal, showSidebar, hideSidebar, + setSelectedSidebarTab, disableTheming, setErrorDetails, } = EditorSlice.actions;