diff --git a/CHANGELOG.md b/CHANGELOG.md index e10c5248a..b3d7fb495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +## [0.30.0] - 2025-04-15 + +### Added + +- `editor-projectLoadFailed` custom event that fires when a project completely fails to load (#1201) +- Added runnerBeingLoaded state to prevent race condition overwrites (#1205) + +### Fixed + +- Bugs in append mode for writing to files in python (#1200) +- `turtle` bug that did not display output on first code run (#1203) + +## [0.29.1] - 2025-02-21 + ### Fixed - Fixed sidebar not correctly reopening (#1196) @@ -1066,7 +1080,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Events in Web Component indicating whether Mission Zero criteria have been met (#113) -[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.0...HEAD +[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.1...HEAD +[0.30.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.30.0 +[0.29.1]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.1 [0.29.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.0 [0.28.14]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.14 [0.28.13]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.13 diff --git a/cypress/e2e/spec-wc-skulpt.cy.js b/cypress/e2e/spec-wc-skulpt.cy.js index 5b733ca8a..db79cf1b4 100644 --- a/cypress/e2e/spec-wc-skulpt.cy.js +++ b/cypress/e2e/spec-wc-skulpt.cy.js @@ -38,7 +38,7 @@ describe("Running the code with skulpt", () => { .shadow() .find(".skulptrunner") .contains(".react-tabs__tab", "Visual output") - .should("not.exist"); + .should("not.be.visible"); cy.get("editor-wc") .shadow() .find(".skulptrunner") diff --git a/package.json b/package.json index af4712906..63d3a2617 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raspberrypifoundation/editor-ui", - "version": "0.29.0", + "version": "0.30.0", "private": true, "dependencies": { "@apollo/client": "^3.7.8", diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index 200c742b0..811467bb3 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -116,7 +116,7 @@ const PyodideWorker = () => { self.content += content if len(self.content) > MAX_FILE_SIZE: raise OSError(f"File '{self.filename}' exceeds maximum file size of {MAX_FILE_SIZE} bytes") - with _original_open(self.filename, "w") as f: + with _original_open(self.filename, mode) as f: f.write(self.content) basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode }) diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 50594e250..0d39afe80 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -223,7 +223,7 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { updatedContent = content; } else if (mode === "a") { updatedContent = - (componentToUpdate ? componentToUpdate.content + "\n" : "") + content; + (componentToUpdate ? componentToUpdate.content : "") + content; } if (componentToUpdate) { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index bea6c9264..fef816ebe 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -307,7 +307,7 @@ describe("When file write event is received", () => { worker.postMessageFromWorker({ method: "handleFileWrite", filename: "existing_file.txt", - content: "new content", + content: "\nnew content", mode: "a", }); expect(dispatchSpy).toHaveBeenCalledWith({ @@ -364,7 +364,7 @@ describe("When file write event is received", () => { worker.postMessageFromWorker({ method: "handleFileWrite", filename: "existing_file.txt", - content: "new content", + content: "\nnew content", mode: "a", }); expect(dispatchSpy).toHaveBeenCalledWith({ diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx index 85baefbbf..60ede272e 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx @@ -6,6 +6,7 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import { loadingRunner } from "../../../../redux/EditorSlice"; +import { getPythonImports } from "../../../../utils/getPythonImports"; const SKULPT_ONLY_MODULES = [ "p5", @@ -45,33 +46,11 @@ const PythonRunner = ({ outputPanels = ["text", "visual"] }) => { setSkulptFallback(true); return; } - const getImports = (code) => { - const codeWithoutMultilineStrings = code.replace( - /'''[\s\S]*?'''|"""[\s\S]*?"""/gm, - "", - ); - const importRegex = - /(?<=^\s*)(from\s+([a-zA-Z0-9_.]+)(\s+import\s+([a-zA-Z0-9_.]+))?)|(?<=^\s*)(import\s+([a-zA-Z0-9_.]+))/gm; - const matches = codeWithoutMultilineStrings.match(importRegex); - const imports = matches - ? matches.map( - (match) => - match - .split(/from|import/) - .filter(Boolean) - .map((s) => s.trim())[0], - ) - : []; - if (code.includes(`# ${t("input.comment.py5")}`)) { - imports.push("py5_imported"); - } - return imports; - }; for (const component of project.components || []) { if (component.extension === "py" && !codeRunTriggered) { try { - const imports = getImports(component.content); + const imports = getPythonImports(component.content, t); const hasSkulptOnlyModules = imports.some((name) => SKULPT_ONLY_MODULES.includes(name), ); diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 99c548d78..3fbe45cfe 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -24,6 +24,7 @@ import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; import { MOBILE_MEDIA_QUERY } from "../../../../../utils/mediaQueryBreakpoints"; +import { getPythonImports } from "../../../../../utils/getPythonImports"; const externalLibraries = { "./pygal/__init__.js": { @@ -55,6 +56,15 @@ const externalLibraries = { }, }; +const VISUAL_LIBRARIES = [ + "pygal", + "py5", + "py5_imported", + "p5", + "sense_hat", + "turtle", +]; + const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const loadedRunner = useSelector((state) => state.editor.loadedRunner); const projectCode = useSelector((state) => state.editor.project.components); @@ -83,10 +93,31 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const settings = useContext(SettingsContext); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); + const project = useSelector((state) => state.editor.project); + + const testForVisualImports = (project) => { + for (const component of project.components || []) { + if (component.extension === "py") { + try { + const imports = getPythonImports(component.content, t); + const hasVisualImports = imports.some((name) => + VISUAL_LIBRARIES.includes(name), + ); + if (hasVisualImports) { + return true; + } + } catch (error) { + console.error("Error occurred while getting imports:", error); + } + } + } + return false; + }; + const [codeHasVisualOutput, setCodeHasVisualOutput] = useState( - senseHatAlwaysEnabled, + !!senseHatAlwaysEnabled || testForVisualImports(project), ); - const [showVisualOutput, setShowVisualOutput] = useState(true); + const [showVisualOutput, setShowVisualOutput] = useState(codeHasVisualOutput); const getInput = () => { const pageInput = document.getElementById("input"); @@ -96,6 +127,14 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { return pageInput || webComponentInput; }; + useEffect(() => { + if (!codeRunTriggered) { + setCodeHasVisualOutput( + !!senseHatAlwaysEnabled || testForVisualImports(project), + ); + } + }, [project, codeRunTriggered, senseHatAlwaysEnabled, t]); + useEffect(() => { if (active && loadedRunner !== "skulpt") { dispatch(setLoadedRunner("skulpt")); @@ -136,15 +175,6 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { } }, [drawTriggered, codeRunTriggered]); - const visualLibraries = [ - "./pygal/__init__.js", - "./py5/__init__.js", - "./py5_imported/__init__.js", - "./p5/__init__.js", - "./_internal_sense_hat/__init__.js", - "src/builtin/turtle/__init__.js", - ]; - const outf = (text) => { if (text !== "") { const node = output.current; @@ -169,10 +199,6 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { // TODO: Handle pre-importing py5_imported when refactored py5 shim imported - if (visualLibraries.includes(library)) { - setCodeHasVisualOutput(true); - } - let localProjectFiles = projectCode .filter((component) => component.name !== "main") .map((component) => `./${component.name}.py`); @@ -436,6 +462,18 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { }); }; + const [tabbedViewSelectedIndex, setTabbedViewSelectedIndex] = useState( + showVisualOutput ? 0 : 1, + ); + + useEffect(() => { + if (showVisualOutput) { + setTabbedViewSelectedIndex(0); + } else { + setTabbedViewSelectedIndex(1); + } + }, [showVisualOutput]); + return (
{ { name: "main", extension: "py", - content: "import py5_imported", + content: "# input.comment.py5", }, ], image_list: [], @@ -563,7 +563,7 @@ describe("When in split view, sense_hat imported and code run", () => { { name: "main", extension: "py", - content: "import _internal_sense_hat", + content: "import sense_hat", }, ], image_list: [], @@ -675,7 +675,7 @@ describe("When in tabbed view, py5 imported and code run", () => { )); }); - test("Output view toggle not shown", () => { + test("Output view toggle is shown", () => { expect( screen.queryByText("outputViewToggle.buttonSplitLabel"), ).toBeInTheDocument(); @@ -704,7 +704,7 @@ describe("When in tabbed view, py5_imported imported and code run", () => { { name: "main", extension: "py", - content: "import py5_imported", + content: "# input.comment.py5", }, ], image_list: [], @@ -724,7 +724,7 @@ describe("When in tabbed view, py5_imported imported and code run", () => { )); }); - test("Output view toggle not shown", () => { + test("Output view toggle is shown", () => { expect( screen.queryByText("outputViewToggle.buttonSplitLabel"), ).toBeInTheDocument(); @@ -769,7 +769,7 @@ describe("When in tabbed view, pygal imported and code run", () => { )); }); - test("Output view toggle not shown", () => { + test("Output view toggle is shown", () => { expect( screen.queryByText("outputViewToggle.buttonSplitLabel"), ).toBeInTheDocument(); @@ -814,7 +814,8 @@ describe("When in tabbed view, turtle imported and code run", () => { )); }); - test("Output view toggle not shown", () => { + test("Output view toggle is shown", () => { + screen.debug(); expect( screen.queryByText("outputViewToggle.buttonSplitLabel"), ).toBeInTheDocument(); @@ -839,7 +840,7 @@ describe("When in tabbed view, sense_hat imported and code run", () => { { name: "main", extension: "py", - content: "import _internal_sense_hat", + content: "import sense_hat", }, ], image_list: [], @@ -859,7 +860,7 @@ describe("When in tabbed view, sense_hat imported and code run", () => { )); }); - test("Output view toggle not shown", () => { + test("Output view toggle is shown", () => { expect( screen.queryByText("outputViewToggle.buttonSplitLabel"), ).toBeInTheDocument(); diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index d6cae9700..adaee80ba 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -26,7 +26,10 @@ import externalStyles from "../assets/stylesheets/ExternalStyles.scss"; import editorStyles from "../assets/stylesheets/index.scss"; import "../assets/stylesheets/Notifications.scss"; import Style from "style-it"; -import { projectOwnerLoadedEvent } from "../events/WebComponentCustomEvents"; +import { + projectLoadFailed, + projectOwnerLoadedEvent, +} from "../events/WebComponentCustomEvents"; const WebComponentLoader = (props) => { const { @@ -112,6 +115,12 @@ const WebComponentLoader = (props) => { } }, [loading, project]); + useEffect(() => { + if (loading === "failed" && !remixLoadFailed) { + document.dispatchEvent(projectLoadFailed); + } + }, [loading, remixLoadFailed]); + useEffect(() => { if (justLoaded) { document.dispatchEvent(projectOwnerLoadedEvent(projectOwner)); diff --git a/src/containers/WebComponentLoader.test.js b/src/containers/WebComponentLoader.test.js index c8a7df9d0..ed42a94c0 100644 --- a/src/containers/WebComponentLoader.test.js +++ b/src/containers/WebComponentLoader.test.js @@ -535,7 +535,7 @@ describe("When user is in state", () => { const mockStore = configureStore(middlewares); const initialState = { editor: { - loading: "idle", + loading: "failed", project: { components: [], }, @@ -581,6 +581,16 @@ describe("When user is in state", () => { reactAppApiEndpoint: "http://localhost:3009/", }); }); + + test("Does not trigger project load failed event", () => { + expect(document.dispatchEvent).not.toHaveBeenCalledWith( + new CustomEvent("editor-projectLoadFailed", { + bubbles: true, + cancelable: false, + composed: true, + }), + ); + }); }); afterEach(async () => { @@ -588,4 +598,52 @@ describe("When user is in state", () => { await act(async () => cookies.remove("theme")); }); }); + + describe("when a project fails to load", () => { + beforeEach(() => { + localStorage.setItem(authKey, JSON.stringify(user)); + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + loading: "failed", + project: { + components: [], + }, + openFiles: [], + focussedFileIndices: [], + hasShownSavePrompt: false, + remixLoadFailed: false, + justLoaded: false, + saveTriggered: false, + }, + instructions: {}, + auth: { user }, + }; + store = mockStore(initialState); + cookies = new Cookies(); + render( ++ , + ); + }); + + test("triggers project load failed event", () => { + expect(document.dispatchEvent).toHaveBeenCalledWith( + new CustomEvent("editor-projectLoadFailed", { + bubbles: true, + cancelable: false, + composed: true, + }), + ); + }); + }); }); diff --git a/src/events/WebComponentCustomEvents.js b/src/events/WebComponentCustomEvents.js index 6378b294d..b79b52d20 100644 --- a/src/events/WebComponentCustomEvents.js +++ b/src/events/WebComponentCustomEvents.js @@ -15,6 +15,10 @@ export const navigateToProjectsPageEvent = webComponentCustomEvent( export const projectIdentifierChangedEvent = (detail) => webComponentCustomEvent("editor-projectIdentifierChanged", detail); +export const projectLoadFailed = webComponentCustomEvent( + "editor-projectLoadFailed", +); + export const projectOwnerLoadedEvent = (detail) => webComponentCustomEvent("editor-projectOwnerLoaded", detail); diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index be193b1f1..60f47acd1 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -140,6 +140,7 @@ export const editorInitialState = { sidebarShowing: true, modals: {}, errorDetails: {}, + runnerBeingLoaded: null | "pyodide" | "skulpt", }; export const EditorSlice = createSlice({ @@ -323,12 +324,15 @@ export const EditorSlice = createSlice({ state.drawTriggered = false; }, loadingRunner: (state, action) => { + state.runnerBeingLoaded = action.payload; state.activeRunner = action.payload; state.codeRunLoading = true; }, setLoadedRunner: (state, action) => { - state.loadedRunner = action.payload; - state.codeRunLoading = false; + if (state.runnerBeingLoaded === action.payload) { + state.loadedRunner = action.payload; + state.codeRunLoading = false; + } }, resetRunner: (state) => { state.activeRunner = null; @@ -339,6 +343,7 @@ export const EditorSlice = createSlice({ state.codeRunLoading = false; state.codeRunTriggered = false; state.codeRunStopped = false; + state.runnerBeingLoaded = null; }, closeAccessDeniedWithAuthModal: (state) => { state.accessDeniedWithAuthModalShowing = false; diff --git a/src/utils/getPythonImports.js b/src/utils/getPythonImports.js new file mode 100644 index 000000000..e06c4f383 --- /dev/null +++ b/src/utils/getPythonImports.js @@ -0,0 +1,22 @@ +export const getPythonImports = (code, t) => { + const codeWithoutMultilineStrings = code.replace( + /'''[\s\S]*?'''|"""[\s\S]*?"""/gm, + "", + ); + const importRegex = + /(?<=^\s*)(from\s+([a-zA-Z0-9_.]+)(\s+import\s+([a-zA-Z0-9_.]+))?)|(?<=^\s*)(import\s+([a-zA-Z0-9_.]+))/gm; + const matches = codeWithoutMultilineStrings.match(importRegex); + const imports = matches + ? matches.map( + (match) => + match + .split(/from|import/) + .filter(Boolean) + .map((s) => s.trim())[0], + ) + : []; + if (code.includes(`# ${t("input.comment.py5")}`)) { + imports.push("py5_imported"); + } + return imports; +};+ ++