From 268c6f66eb48d4bd5671f804b62e9556f48afd19 Mon Sep 17 00:00:00 2001 From: Lois Wells <88904316+loiswells97@users.noreply.github.com> Date: Tue, 7 Jan 2025 09:01:29 +0000 Subject: [PATCH 01/18] Support output tabs in pyodide (#1157) --- CHANGELOG.md | 4 + .../PyodideRunner/PyodideRunner.jsx | 75 +++++++++++-------- .../PyodideRunner/PyodideRunner.test.js | 49 +++++++++++- .../Runners/PythonRunner/PythonRunner.jsx | 5 +- 4 files changed, 101 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1db3ed5..e087a85cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Added + +- Support for the `outputPanels` attribute in the `PyodideRunner` (#1157) + ## [0.28.14] - 2025-01-06 ### Fixed diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 157451968..c2f3a9d86 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -31,7 +31,7 @@ const getWorkerURL = (url) => { return URL.createObjectURL(blob); }; -const PyodideRunner = ({ active }) => { +const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { const [pyodideWorker, setPyodideWorker] = useState(null); useEffect(() => { @@ -65,6 +65,9 @@ const PyodideRunner = ({ active }) => { const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); const senseHatAlways = useSelector((s) => s.editor.senseHatAlwaysEnabled); const queryParams = new URLSearchParams(window.location.search); + const singleOutputPanel = outputPanels.length === 1; + const showVisualOutputPanel = outputPanels.includes("visual"); + const showTextOutputPanel = outputPanels.includes("text"); const showVisualTab = queryParams.get("show_visual_tab") === "true"; const [hasVisual, setHasVisual] = useState(showVisualTab || senseHatAlways); const [visuals, setVisuals] = useState([]); @@ -195,8 +198,10 @@ const PyodideRunner = ({ active }) => { }; const handleVisual = (origin, content) => { - setHasVisual(true); - setVisuals((array) => [...array, { origin, content }]); + if (showVisualOutputPanel) { + setHasVisual(true); + setVisuals((array) => [...array, { origin, content }]); + } }; const handleSenseHatEvent = (type) => { @@ -326,12 +331,16 @@ const PyodideRunner = ({ active }) => { "pyodiderunner--active": active, })} > - {isSplitView ? ( + {isSplitView || singleOutputPanel ? ( <> - {hasVisual && ( + {hasVisual && showVisualOutputPanel && (
-
+
@@ -348,30 +357,36 @@ const PyodideRunner = ({ active }) => {
)} -
- -
- - - - {t("output.textOutput")} - - - - {!hasVisual && !isEmbedded && isMobile && ( - - )} -
- - -

-              
-
-
+ {showTextOutputPanel && ( +
+ +
+ + + + {t("output.textOutput")} + + + + {!hasVisual && !isEmbedded && isMobile && ( + + )} +
+ + +

+                
+
+
+ )} ) : ( diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index c8bd31f40..25401d731 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -17,6 +17,7 @@ import { setProject, setLoadedRunner, stopCodeRun, + setSenseHatAlwaysEnabled, } from "../../../../../redux/EditorSlice.js"; import store from "../../../../../app/store"; @@ -26,7 +27,11 @@ global.fetch = jest.fn(); const project = { components: [ { name: "a", extension: "py", content: "print('a')" }, - { name: "main", extension: "py", content: "print('hello')" }, + { + name: "main", + extension: "py", + content: "print('hello')", + }, ], image_list: [ { filename: "image1.jpg", url: "http://example.com/image1.jpg" }, @@ -368,3 +373,45 @@ describe("When not active and code run triggered", () => { expect(postMessage).not.toHaveBeenCalled(); }); }); + +describe("When there is visual output", () => { + beforeEach(() => { + act(() => { + store.dispatch(setSenseHatAlwaysEnabled(true)); + }); + }); + + test("displays both text and visual tabs by default", () => { + render( + + + , + ); + expect(screen.queryByText("output.textOutput")).toBeInTheDocument(); + expect(screen.queryByText("output.visualOutput")).toBeInTheDocument(); + }); + + test("only displays text tab when outputPanels is set to just text", () => { + render( + + + , + ); + expect(screen.queryByText("output.textOutput")).toBeInTheDocument(); + expect(screen.queryByText("output.visualOutput")).not.toBeInTheDocument(); + }); + + test("only displays visual tab when outputPanels is set to just visual", () => { + render( + + + , + ); + expect(screen.queryByText("output.textOutput")).not.toBeInTheDocument(); + expect(screen.queryByText("output.visualOutput")).toBeInTheDocument(); + }); + + afterEach(() => { + store.dispatch(setSenseHatAlwaysEnabled(false)); + }); +}); diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx index 48babc88b..85baefbbf 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx @@ -92,7 +92,10 @@ const PythonRunner = ({ outputPanels = ["text", "visual"] }) => { }, [project, codeRunTriggered, senseHatAlwaysEnabled, skulptFallback, t]); return ( <> - + Date: Fri, 10 Jan 2025 16:12:03 +0000 Subject: [PATCH 02/18] Downloading the instructions (#1160) ## What's Changed? - Includes instructions in the project download if there are any - Adds a list of reserved file names which includes `INSTRUCTIONS.md` to prevent clashes Closes https://github.com/RaspberryPiFoundation/digital-editor-issues/issues/374 --- CHANGELOG.md | 5 ++ .../DownloadButton/DownloadButton.jsx | 4 ++ .../DownloadButton/DownloadButton.test.js | 58 +++++++++++++++++++ src/components/Modals/NewFileModal.test.js | 11 ++++ src/components/Modals/RenameFileModal.test.js | 11 ++++ src/utils/componentNameValidation.js | 7 +++ src/utils/i18n.js | 2 + 7 files changed, 98 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e087a85cb..37d7d0a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Support for the `outputPanels` attribute in the `PyodideRunner` (#1157) +- Downloading project instructions (#1160) + +### Changed + +- Made `INSTRUCTIONS.md` a reserved file name (#1160) ## [0.28.14] - 2025-01-06 diff --git a/src/components/DownloadButton/DownloadButton.jsx b/src/components/DownloadButton/DownloadButton.jsx index eb9c15b8d..b37342633 100644 --- a/src/components/DownloadButton/DownloadButton.jsx +++ b/src/components/DownloadButton/DownloadButton.jsx @@ -39,6 +39,10 @@ const DownloadButton = (props) => { const zip = new JSZip(); + if (project.instructions) { + zip.file("INSTRUCTIONS.md", project.instructions); + } + project.components.forEach((file) => { zip.file(`${file.name}.${file.extension}`, file.content); }); diff --git a/src/components/DownloadButton/DownloadButton.test.js b/src/components/DownloadButton/DownloadButton.test.js index ef9e38253..1d8a5a655 100644 --- a/src/components/DownloadButton/DownloadButton.test.js +++ b/src/components/DownloadButton/DownloadButton.test.js @@ -25,6 +25,7 @@ describe("Downloading project with name set", () => { project: { name: "My epic project", identifier: "hello-world-project", + instructions: "print hello world to the console", components: [ { name: "main", @@ -53,6 +54,18 @@ describe("Downloading project with name set", () => { expect(downloadButton).toBeInTheDocument(); }); + test("Clicking download zips instructions", async () => { + fireEvent.click(downloadButton); + const JSZipInstance = JSZip.mock.instances[0]; + const mockFile = JSZipInstance.file; + await waitFor(() => + expect(mockFile).toHaveBeenCalledWith( + "INSTRUCTIONS.md", + "print hello world to the console", + ), + ); + }); + test("Clicking download zips project file content", async () => { fireEvent.click(downloadButton); const JSZipInstance = JSZip.mock.instances[0]; @@ -123,3 +136,48 @@ describe("Downloading project with no name set", () => { ); }); }); + +describe("Downloading project with no instructions set", () => { + let downloadButton; + + beforeEach(() => { + JSZip.mockClear(); + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: { + name: "My epic project", + identifier: "hello-world-project", + components: [ + { + name: "main", + extension: "py", + content: "", + }, + ], + image_list: [], + }, + }, + }; + const store = mockStore(initialState); + render( + + {}} /> + , + ); + downloadButton = screen.queryByText("Download").parentElement; + }); + + test("Clicking download button does not zip instructions", async () => { + fireEvent.click(downloadButton); + const JSZipInstance = JSZip.mock.instances[0]; + const mockFile = JSZipInstance.file; + await waitFor(() => + expect(mockFile).not.toHaveBeenCalledWith( + "INSTRUCTIONS.md", + expect.anything(), + ), + ); + }); +}); diff --git a/src/components/Modals/NewFileModal.test.js b/src/components/Modals/NewFileModal.test.js index 69eb5a39d..8b0035fbf 100644 --- a/src/components/Modals/NewFileModal.test.js +++ b/src/components/Modals/NewFileModal.test.js @@ -81,6 +81,17 @@ describe("Testing the new file modal", () => { expect(store.getActions()).toEqual(expectedActions); }); + test("Reserved file name throws error", () => { + fireEvent.change(inputBox, { target: { value: "INSTRUCTIONS.md" } }); + fireEvent.click(saveButton); + const expectedActions = [ + setNameError("filePanel.errors.reservedFileName", { + fileName: "INSTRUCTIONS.md", + }), + ]; + expect(store.getActions()).toEqual(expectedActions); + }); + test("Unsupported extension throws error", () => { fireEvent.change(inputBox, { target: { value: "file1.js" } }); fireEvent.click(saveButton); diff --git a/src/components/Modals/RenameFileModal.test.js b/src/components/Modals/RenameFileModal.test.js index be2df5ac6..112f9b16a 100644 --- a/src/components/Modals/RenameFileModal.test.js +++ b/src/components/Modals/RenameFileModal.test.js @@ -91,6 +91,17 @@ describe("Testing the rename file modal", () => { expect(store.getActions()).toEqual(expectedActions); }); + test("Reserved file name throws error", () => { + fireEvent.change(inputBox, { target: { value: "INSTRUCTIONS.md" } }); + fireEvent.click(saveButton); + const expectedActions = [ + setNameError("filePanel.errors.reservedFileName", { + fileName: "INSTRUCTIONS.md", + }), + ]; + expect(store.getActions()).toEqual(expectedActions); + }); + test("Unchanged file name does not throw error", () => { fireEvent.click(saveButton); const expectedActions = [ diff --git a/src/utils/componentNameValidation.js b/src/utils/componentNameValidation.js index 003c6c562..26ce6c689 100644 --- a/src/utils/componentNameValidation.js +++ b/src/utils/componentNameValidation.js @@ -5,6 +5,8 @@ const allowedExtensions = { html: ["html", "css", "js"], }; +const reservedFileNames = ["INSTRUCTIONS.md"]; + const allowedExtensionsString = (projectType, t) => { const extensionsList = allowedExtensions[projectType]; if (extensionsList.length === 1) { @@ -19,6 +21,7 @@ const allowedExtensionsString = (projectType, t) => { const isValidFileName = (fileName, projectType, componentNames) => { const extension = fileName.split(".").slice(1).join("."); if ( + !reservedFileNames.includes(fileName) && allowedExtensions[projectType].includes(extension) && !componentNames.includes(fileName) && fileName.split(" ").length === 1 @@ -44,6 +47,10 @@ export const validateFileName = ( (currentFileName && fileName === currentFileName) ) { callback(); + } else if (reservedFileNames.includes(fileName)) { + dispatch( + setNameError(t("filePanel.errors.reservedFileName", { fileName })), + ); } else if (componentNames.includes(fileName)) { dispatch(setNameError(t("filePanel.errors.notUnique"))); } else if (fileName.split(" ").length > 1) { diff --git a/src/utils/i18n.js b/src/utils/i18n.js index b61ab4d92..eeab05286 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -130,6 +130,8 @@ i18n }, filePanel: { errors: { + reservedFileName: + "{{fileName}} is a reserved file name. Please choose a different name.", containsSpaces: "File names must not contain spaces.", generalError: "Error", notUnique: "File names must be unique.", From 1eae407c8253fd8618fcb2f4527ad64533a8472f Mon Sep 17 00:00:00 2001 From: Lois Wells <88904316+loiswells97@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:34:39 +0000 Subject: [PATCH 03/18] Hack open function to write files in main thread (#1146) ## Things that need doing - [x] Support `w` mode - [x] Support `a` mode - [x] Support `x` mode - [x] Support creating files when the specified file name does not match an existing file - [x] Support `with open(filename) as f` pattern (currently returning `CustomFile does not support the context manager protocol`) - [x] Re-enable `pyodide-http` patch - [x] Think about limiting the number of files the user can create to avoid overloading the server - [x] Ensure that file size limit applies to generated files --- CHANGELOG.md | 1 + cypress/e2e/spec-wc-pyodide.cy.js | 52 +++++++ src/PyodideWorker.js | 86 +++++++++-- .../Editor/EditorPanel/EditorPanel.jsx | 37 ++++- src/components/Editor/Output/Output.test.js | 2 + .../PyodideRunner/PyodideRunner.jsx | 47 ++++++- .../PyodideRunner/PyodideRunner.test.js | 133 +++++++++++++++++- .../PyodideRunner/PyodideWorker.test.js | 33 +++-- .../PyodideRunner/VisualOutputPane.jsx | 111 ++++++++------- src/redux/EditorSlice.js | 19 ++- src/redux/EditorSlice.test.js | 79 +++++++++++ 11 files changed, 509 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d7d0a17..6795ef2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Ability to write to files in `python` (#1146) - Support for the `outputPanels` attribute in the `PyodideRunner` (#1157) - Downloading project instructions (#1160) diff --git a/cypress/e2e/spec-wc-pyodide.cy.js b/cypress/e2e/spec-wc-pyodide.cy.js index 858c308a9..73bcbd314 100644 --- a/cypress/e2e/spec-wc-pyodide.cy.js +++ b/cypress/e2e/spec-wc-pyodide.cy.js @@ -91,6 +91,58 @@ describe("Running the code with pyodide", () => { .should("contain", "Hello Lois"); }); + it("runs a simple program to write to a file", () => { + runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")'); + cy.get("editor-wc") + .shadow() + .contains(".files-list-item", "output.txt") + .click(); + cy.get("editor-wc") + .shadow() + .find(".cm-editor") + .should("contain", "Hello world"); + }); + + it("errors when trying to write to an existing file in 'x' mode", () => { + runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")'); + cy.get("editor-wc") + .shadow() + .find(".files-list-item") + .should("contain", "output.txt"); + runCode('with open("output.txt", "x") as f:\n\tf.write("Something else")'); + cy.get("editor-wc") + .shadow() + .find(".error-message__content") + .should( + "contain", + "FileExistsError: File 'output.txt' already exists on line 1 of main.py", + ); + }); + + it("updates the file in the editor when the content is updated programatically", () => { + runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")'); + cy.get("editor-wc") + .shadow() + .find("div[class=cm-content]") + .invoke( + "text", + 'with open("output.txt", "a") as f:\n\tf.write("Hello again world")', + ); + cy.get("editor-wc") + .shadow() + .contains(".files-list-item", "output.txt") + .click(); + cy.get("editor-wc") + .shadow() + .find(".btn--run") + .should("not.be.disabled") + .click(); + cy.get("editor-wc") + .shadow() + .find(".cm-editor") + .should("contain", "Hello again world"); + }); + it("runs a simple program with a built-in python module", () => { runCode("from math import floor, pi\nprint(floor(pi))"); cy.get("editor-wc") diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index b50981742..200c742b0 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -60,12 +60,6 @@ const PyodideWorker = () => { const runPython = async (python) => { stopped = false; - await pyodide.loadPackage("pyodide_http"); - - await pyodide.runPythonAsync(` - import pyodide_http - pyodide_http.patch_all() - `); try { await withSupportForPackages(python, async () => { @@ -98,6 +92,52 @@ const PyodideWorker = () => { await pyodide.loadPackagesFromImports(python); checkIfStopped(); + await pyodide.runPythonAsync( + ` + import basthon + import builtins + import os + + MAX_FILES = 100 + MAX_FILE_SIZE = 8500000 + + def _custom_open(filename, mode="r", *args, **kwargs): + if "x" in mode and os.path.exists(filename): + raise FileExistsError(f"File '{filename}' already exists") + if ("w" in mode or "a" in mode or "x" in mode) and "b" not in mode: + if len(os.listdir()) > MAX_FILES and not os.path.exists(filename): + raise OSError(f"File system limit reached, no more than {MAX_FILES} files allowed") + class CustomFile: + def __init__(self, filename): + self.filename = filename + self.content = "" + + def write(self, content): + 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: + f.write(self.content) + basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode }) + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + return CustomFile(filename) + else: + return _original_open(filename, mode, *args, **kwargs) + + # Override the built-in open function + builtins.open = _custom_open + `, + { filename: "__custom_open__.py" }, + ); await runPythonFn(); for (let name of imports) { @@ -337,6 +377,12 @@ const PyodideWorker = () => { postMessage({ method: "handleVisual", origin, content }); }, + write_file: (event) => { + const filename = event.toJs().get("filename"); + const content = event.toJs().get("content"); + const mode = event.toJs().get("mode"); + postMessage({ method: "handleFileWrite", filename, content, mode }); + }, locals: () => pyodide.runPython("globals()"), }, }; @@ -346,7 +392,7 @@ const PyodideWorker = () => { await pyodide.runPythonAsync(` # Clear all user-defined variables and modules for name in dir(): - if not name.startswith('_'): + if not name.startswith('_') and not name=='basthon': del globals()[name] `); postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer }); @@ -364,6 +410,8 @@ const PyodideWorker = () => { pyodide = await pyodidePromise; + pyodide.registerJsModule("basthon", fakeBasthonPackage); + await pyodide.runPythonAsync(` __old_input__ = input def __patched_input__(prompt=False): @@ -373,6 +421,18 @@ const PyodideWorker = () => { __builtins__.input = __patched_input__ `); + await pyodide.runPythonAsync(` + import builtins + # Save the original open function + _original_open = builtins.open + `); + + await pyodide.loadPackage("pyodide-http"); + await pyodide.runPythonAsync(` + import pyodide_http + pyodide_http.patch_all() + `); + if (supportsAllFeatures) { stdinBuffer = stdinBuffer || new Int32Array(new SharedArrayBuffer(1024 * 1024)); // 1 MiB @@ -416,6 +476,14 @@ const PyodideWorker = () => { const lines = trace.split("\n"); + // if the third from last line matches /File "__custom_open__\.py", line (\d+)/g then strip off the last three lines + if ( + lines.length > 3 && + /File "__custom_open__\.py", line (\d+)/g.test(lines[lines.length - 3]) + ) { + lines.splice(-3, 3); + } + const snippetLine = lines[lines.length - 2]; // print("hi")invalid const caretLine = lines[lines.length - 1]; // ^^^^^^^ @@ -424,7 +492,9 @@ const PyodideWorker = () => { ? [snippetLine.slice(4), caretLine.slice(4)].join("\n") : ""; - const matches = [...trace.matchAll(/File "(.*)", line (\d+)/g)]; + const matches = [ + ...trace.matchAll(/File "(?!__custom_open__\.py)(.*)", line (\d+)/g), + ]; const match = matches[matches.length - 1]; const path = match ? match[1] : ""; diff --git a/src/components/Editor/EditorPanel/EditorPanel.jsx b/src/components/Editor/EditorPanel/EditorPanel.jsx index 951cfb70b..21a930736 100644 --- a/src/components/Editor/EditorPanel/EditorPanel.jsx +++ b/src/components/Editor/EditorPanel/EditorPanel.jsx @@ -2,7 +2,10 @@ import "../../../assets/stylesheets/EditorPanel.scss"; import React, { useRef, useEffect, useContext, useState } from "react"; import { useSelector, useDispatch } from "react-redux"; -import { updateProjectComponent } from "../../../redux/EditorSlice"; +import { + setCascadeUpdate, + updateProjectComponent, +} from "../../../redux/EditorSlice"; import { useCookies } from "react-cookie"; import { useTranslation } from "react-i18next"; import { basicSetup } from "codemirror"; @@ -27,8 +30,10 @@ const MAX_CHARACTERS = 8500000; const EditorPanel = ({ extension = "html", fileName = "index" }) => { const editor = useRef(); + const editorViewRef = useRef(); const project = useSelector((state) => state.editor.project); const readOnly = useSelector((state) => state.editor.readOnly); + const cascadeUpdate = useSelector((state) => state.editor.cascadeUpdate); const [cookies] = useCookies(["theme", "fontSize"]); const dispatch = useDispatch(); const { t } = useTranslation(); @@ -40,7 +45,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { updateProjectComponent({ extension: extension, name: fileName, - code: content, + content, + cascadeUpdate: false, }), ); }; @@ -74,11 +80,11 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { window.matchMedia("(prefers-color-scheme:dark)").matches); const editorTheme = isDarkMode ? editorDarkTheme : editorLightTheme; - useEffect(() => { - const file = project.components.find( - (item) => item.extension === extension && item.name === fileName, - ); + const file = project.components.find( + (item) => item.extension === extension && item.name === fileName, + ); + useEffect(() => { if (!file) { return; } @@ -123,6 +129,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { parent: editor.current, }); + editorViewRef.current = view; + // 'aria-hidden' to fix keyboard access accessibility error view.scrollDOM.setAttribute("aria-hidden", "true"); @@ -138,6 +146,23 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { }; }, [cookies]); + useEffect(() => { + if ( + cascadeUpdate && + editorViewRef.current && + file.content !== editorViewRef.current.state.doc.toString() + ) { + editorViewRef.current.dispatch({ + changes: { + from: 0, + to: editorViewRef.current.state.doc.length, + insert: file.content, + }, + }); + dispatch(setCascadeUpdate(false)); + } + }, [file, cascadeUpdate, editorViewRef]); + return ( <>
diff --git a/src/components/Editor/Output/Output.test.js b/src/components/Editor/Output/Output.test.js index 0c23a4c37..2b736ac1f 100644 --- a/src/components/Editor/Output/Output.test.js +++ b/src/components/Editor/Output/Output.test.js @@ -26,6 +26,8 @@ describe("Output component", () => { project: { components: [], }, + focussedFileIndices: [0], + openFiles: [["main.py"]], }, auth: { user, diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index c2f3a9d86..50594e250 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -8,6 +8,8 @@ import { setError, codeRunHandled, setLoadedRunner, + updateProjectComponent, + addProjectComponent, } from "../../../../../redux/EditorSlice"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import { useMediaQuery } from "react-responsive"; @@ -51,6 +53,10 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { const projectImages = useSelector((s) => s.editor.project.image_list); const projectCode = useSelector((s) => s.editor.project.components); const projectIdentifier = useSelector((s) => s.editor.project.identifier); + const focussedFileIndex = useSelector( + (state) => state.editor.focussedFileIndices, + )[0]; + const openFiles = useSelector((state) => state.editor.openFiles)[0]; const user = useSelector((s) => s.auth.user); const userId = user?.profile?.user; const isSplitView = useSelector((s) => s.editor.isSplitView); @@ -97,6 +103,16 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { data.info, ); break; + case "handleFileWrite": + const cascadeUpdate = + openFiles[focussedFileIndex] === data.filename; + handleFileWrite( + data.filename, + data.content, + data.mode, + cascadeUpdate, + ); + break; case "handleVisual": handleVisual(data.origin, data.content); break; @@ -108,7 +124,7 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { } }; } - }, [pyodideWorker]); + }, [pyodideWorker, projectCode, openFiles, focussedFileIndex]); useEffect(() => { if (codeRunTriggered && active && output.current) { @@ -197,6 +213,35 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { disableInput(); }; + const handleFileWrite = (filename, content, mode, cascadeUpdate) => { + const [name, extension] = filename.split("."); + const componentToUpdate = projectCode.find( + (item) => item.extension === extension && item.name === name, + ); + let updatedContent; + if (mode === "w" || mode === "x") { + updatedContent = content; + } else if (mode === "a") { + updatedContent = + (componentToUpdate ? componentToUpdate.content + "\n" : "") + content; + } + + if (componentToUpdate) { + dispatch( + updateProjectComponent({ + extension, + name, + content: updatedContent, + cascadeUpdate, + }), + ); + } else { + dispatch( + addProjectComponent({ name, extension, content: updatedContent }), + ); + } + }; + const handleVisual = (origin, content) => { if (showVisualOutputPanel) { setHasVisual(true); diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index 25401d731..bea6c9264 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -18,6 +18,8 @@ import { setLoadedRunner, stopCodeRun, setSenseHatAlwaysEnabled, + openFile, + setFocussedFileIndex, } from "../../../../../redux/EditorSlice.js"; import store from "../../../../../app/store"; @@ -27,11 +29,8 @@ global.fetch = jest.fn(); const project = { components: [ { name: "a", extension: "py", content: "print('a')" }, - { - name: "main", - extension: "py", - content: "print('hello')", - }, + { name: "main", extension: "py", content: "print('hello')" }, + { name: "existing_file", extension: "txt", content: "hello" }, ], image_list: [ { filename: "image1.jpg", url: "http://example.com/image1.jpg" }, @@ -256,6 +255,130 @@ describe("When output is received", () => { }); }); +describe("When file write event is received", () => { + let worker; + beforeEach(() => { + render( + + , + , + ); + updateRunner({ project }); + worker = PyodideWorker.getLastInstance(); + }); + + test("it overwrites existing files in 'w' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "existing_file.txt", + content: "new content", + mode: "w", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/updateProjectComponent", + payload: { + name: "existing_file", + extension: "txt", + content: "new content", + cascadeUpdate: false, + }, + }); + }); + + test("it creates new file if not already existing in 'w' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "new_file.txt", + content: "new content", + mode: "w", + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/addProjectComponent", + payload: { + name: "new_file", + extension: "txt", + content: "new content", + }, + }); + }); + + test("it appends to existing files in 'a' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "existing_file.txt", + content: "new content", + mode: "a", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/updateProjectComponent", + payload: { + name: "existing_file", + extension: "txt", + content: "hello\nnew content", + cascadeUpdate: false, + }, + }); + }); + + test("it creates new file if not already existing in 'a' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "new_file.txt", + content: "new content", + mode: "a", + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/addProjectComponent", + payload: { + name: "new_file", + extension: "txt", + content: "new content", + }, + }); + }); + + test("it creates new file if not already existing in 'x' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "new_file.txt", + content: "new content", + mode: "x", + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/addProjectComponent", + payload: { + name: "new_file", + extension: "txt", + content: "new content", + }, + }); + }); + + test("it cascades updates if the file is open and focused", () => { + store.dispatch(openFile({ name: "existing_file", extension: "txt" })); + store.dispatch(setFocussedFileIndex({ panelIndex: 0, fileIndex: 1 })); + + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "existing_file.txt", + content: "new content", + mode: "a", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/updateProjectComponent", + payload: { + name: "existing_file", + extension: "txt", + content: "hello\nnew content", + cascadeUpdate: false, + }, + }); + }); +}); + describe("When visual output is received", () => { beforeEach(() => { render( diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js index f54b07734..702cc4223 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js @@ -89,29 +89,23 @@ describe("PyodideWorker", () => { }); test("it patches the input function", async () => { - await worker.onmessage({ - data: { - method: "runPython", - python: "print('hello')", - }, - }); expect(pyodide.runPythonAsync).toHaveBeenCalledWith( expect.stringMatching(/__builtins__.input = __patched_input__/), ); }); test("it patches urllib and requests modules", async () => { - await worker.onmessage({ - data: { - method: "runPython", - python: "print('hello')", - }, - }); expect(pyodide.runPythonAsync).toHaveBeenCalledWith( expect.stringMatching(/pyodide_http.patch_all()/), ); }); + test("it saves original open function", async () => { + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/_original_open = builtins.open/), + ); + }); + test("it tries to load package from file system", async () => { pyodide._api.pyodide_code.find_imports = () => new MockPythonArray("numpy"); await worker.onmessage({ @@ -178,6 +172,21 @@ describe("PyodideWorker", () => { }); }); + test("it patches the open function", async () => { + await worker.onmessage({ + data: { + method: "runPython", + python: "print('hello')", + }, + }); + await waitFor(() => + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/builtins.open = _custom_open/), + { filename: "__custom_open__.py" }, + ), + ); + }); + test("it runs the python code", async () => { await worker.onmessage({ data: { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx index 339da793c..dadb12c6f 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useSelector } from "react-redux"; import AstroPiModel from "../../../../AstroPiModel/AstroPiModel"; import Highcharts from "highcharts"; @@ -8,13 +8,68 @@ const VisualOutputPane = ({ visuals, setVisuals }) => { const senseHatAlways = useSelector((s) => s.editor.senseHatAlwaysEnabled); const output = useRef(); + const showVisual = useCallback((visual, output) => { + switch (visual.origin) { + case "sense_hat": + output.current.textContent = JSON.stringify(visual.content); + break; + case "pygal": + const chartContent = { + ...visual.content, + chart: { + ...visual.content.chart, + events: { + ...visual.content.chart.events, + load: function () { + this.renderTo.style.overflow = "visible"; + }, + }, + }, + tooltip: { + ...visual.content.tooltip, + formatter: + visual.content.chart.type === "pie" + ? function () { + return this.key + ": " + this.y; + } + : null, + }, + }; + Highcharts.chart(output.current, chartContent); + break; + case "turtle": + output.current.innerHTML = elementFromProps(visual.content).outerHTML; + break; + case "matplotlib": + // convert visual.content from Uint8Array to jpg + const img = document.createElement("img"); + img.style = "max-width: 100%; max-height: 100%;"; + img.src = `data:image/jpg;base64,${window.btoa( + String.fromCharCode(...new Uint8Array(visual.content)), + )}`; + output.current.innerHTML = img.outerHTML; + break; + default: + throw new Error(`Unsupported origin: ${visual.origin}`); + } + + visual.showing = true; + return visual; + }, []); + + const showVisuals = useCallback( + (visuals, output) => + visuals.map((v) => (v.showing ? v : showVisual(v, output))), + [showVisual], + ); + useEffect(() => { if (visuals.length === 0) { output.current.innerHTML = ""; } else if (visuals.some((v) => !v.showing)) { setVisuals((visuals) => showVisuals(visuals, output)); } - }, [visuals, setVisuals]); + }, [visuals, setVisuals, showVisuals]); return (
@@ -24,58 +79,6 @@ const VisualOutputPane = ({ visuals, setVisuals }) => { ); }; -const showVisuals = (visuals, output) => - visuals.map((v) => (v.showing ? v : showVisual(v, output))); - -const showVisual = (visual, output) => { - switch (visual.origin) { - case "sense_hat": - output.current.textContent = JSON.stringify(visual.content); - break; - case "pygal": - const chartContent = { - ...visual.content, - chart: { - ...visual.content.chart, - events: { - ...visual.content.chart.events, - load: function () { - this.renderTo.style.overflow = "visible"; - }, - }, - }, - tooltip: { - ...visual.content.tooltip, - formatter: - visual.content.chart.type === "pie" - ? function () { - return this.key + ": " + this.y; - } - : null, - }, - }; - Highcharts.chart(output.current, chartContent); - break; - case "turtle": - output.current.innerHTML = elementFromProps(visual.content).outerHTML; - break; - case "matplotlib": - // convert visual.content from Uint8Array to jpg - const img = document.createElement("img"); - img.style = "max-width: 100%; max-height: 100%;"; - img.src = `data:image/jpg;base64,${window.btoa( - String.fromCharCode(...new Uint8Array(visual.content)), - )}`; - output.current.innerHTML = img.outerHTML; - break; - default: - throw new Error(`Unsupported origin: ${visual.origin}`); - } - - visual.showing = true; - return visual; -}; - const elementFromProps = (map) => { const tag = map.get("tag"); if (!tag) { diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index ec5119c87..624ef3dd2 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -95,6 +95,7 @@ export const loadProjectList = createAsyncThunk( const initialState = { project: {}, + cascadeUpdate: false, readOnly: false, saveTriggered: false, saving: "idle", @@ -197,7 +198,7 @@ export const EditorSlice = createSlice({ state.project.components.push({ name: action.payload.name, extension: action.payload.extension, - content: "", + content: action.payload.content || "", }); state.saving = "idle"; }, @@ -262,18 +263,22 @@ export const EditorSlice = createSlice({ state.saveTriggered = true; }, updateProjectComponent: (state, action) => { - const extension = action.payload.extension; - const fileName = action.payload.name; - const code = action.payload.code; + const { + extension, + name: fileName, + content, + cascadeUpdate, + } = action.payload; const mapped = state.project.components.map((item) => { if (item.extension !== extension || item.name !== fileName) { return item; } - return { ...item, ...{ content: code } }; + return { ...item, ...{ content } }; }); state.project.components = mapped; + state.cascadeUpdate = cascadeUpdate; }, updateProjectName: (state, action) => { state.project.name = action.payload; @@ -295,6 +300,9 @@ export const EditorSlice = createSlice({ } state.saving = "idle"; }, + setCascadeUpdate: (state, action) => { + state.cascadeUpdate = action.payload; + }, setError: (state, action) => { state.error = action.payload; }, @@ -454,6 +462,7 @@ export const { setEmbedded, setIsOutputOnly, setBrowserPreview, + setCascadeUpdate, setError, setIsSplitView, setNameError, diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index 186dac7a8..7c7baf6e2 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -11,6 +11,9 @@ import reducer, { setIsOutputOnly, setErrorDetails, setReadOnly, + addProjectComponent, + updateProjectComponent, + setCascadeUpdate, } from "./EditorSlice"; const mockCreateRemix = jest.fn(); @@ -104,6 +107,82 @@ test("Action setReadOnly correctly sets readOnly", () => { expect(reducer(previousState, setReadOnly(true))).toEqual(expectedState); }); +test("Action addProjectComponent adds component to project with correct content", () => { + const previousState = { + project: { + components: [], + }, + }; + const expectedState = { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello world')", + }, + ], + }, + saving: "idle", + }; + expect( + reducer( + previousState, + addProjectComponent({ + name: "main", + extension: "py", + content: "print('hello world')", + }), + ), + ).toEqual(expectedState); +}); + +test("Action updateProjectComponent updates component in project with correct content", () => { + const previousState = { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello world')", + }, + ], + }, + cascadeUpdate: false, + }; + const expectedState = { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello there world!')", + }, + ], + }, + cascadeUpdate: true, + }; + expect( + reducer( + previousState, + updateProjectComponent({ + name: "main", + extension: "py", + content: "print('hello there world!')", + cascadeUpdate: true, + }), + ), + ).toEqual(expectedState); +}); + +test("Action setCascadeUpdate sets cascadeUpdate correctly", () => { + const previousState = { cascadeUpdate: true }; + const expectedState = { cascadeUpdate: false }; + expect(reducer(previousState, setCascadeUpdate(false))).toEqual( + expectedState, + ); +}); + test("Showing rename modal sets file state and showing status", () => { const previousState = { renameFileModalShowing: false, From 5d0c69caf145ffe5a9587ac2851a720b3a405f83 Mon Sep 17 00:00:00 2001 From: Dan Halson Date: Wed, 29 Jan 2025 09:13:22 +0000 Subject: [PATCH 04/18] Try pinning previous ubuntu version to avoid cloudflare issue (#1178) Test deployment to try out a potential workaround for cloudflare issue: ``` Update - We are continuing to work on a fix for native CRC32 checksum handling in R2 APIs. If you run into an issue, please see the documentation for workarounds based on the SDK that you are using: https://developers.cloudflare.com/r2/examples/aws/ Jan 22, 2025 - 16:33 UTC Update - We are continuing to work on a fix for this issue. Jan 17, 2025 - 11:23 UTC Identified - AWS recently updated their SDKs to enable CRC32 checksums on multiple object operations by default. R2 does not currently support CRC32 checksums, and the default configurations will return header related errors such as Header 'x-amz-checksum-algorithm' with value 'CRC32' not implemented. Impacted users can either pin AWS SDKs to a prior version or modify the configuration to restore the prior default behavior of not checking checksums on upload. For more details, see the examples section of the R2 Docs for the relevant SDK: https://developers.cloudflare.com/r2/examples/aws/ Jan 17, 2025 - 03:20 UTC ``` --------- Co-authored-by: Conor --- .github/workflows/changelog.yml | 34 ++++++++++++++++----------------- .github/workflows/deploy.yml | 6 ++++++ CHANGELOG.md | 4 ++++ 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 82567be5c..6f34a5ca5 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -3,26 +3,26 @@ name: Changelog on: pull_request: branches: - - '**' + - "**" jobs: changelog-updated: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check if changelog updated - id: changed-files-specific - uses: tj-actions/changed-files@v29.0.4 - with: - base_sha: ${{ github.event.pull_request.base.sha }} - files: | - CHANGELOG.md - env: - GITHUB_TOKEN: ${{ github.token }} + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check if changelog updated + id: changed-files-specific + uses: tj-actions/changed-files@v29.0.4 + with: + base_sha: ${{ github.event.pull_request.base.sha }} + files: | + CHANGELOG.md + env: + GITHUB_TOKEN: ${{ github.token }} - - name: Fail job if changelog not updated - if: steps.changed-files-specific.outputs.any_changed == 'false' - run: | - exit 1 + - name: Fail job if changelog not updated + if: steps.changed-files-specific.outputs.any_changed == 'false' + run: | + exit 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index de3b9fe93..db09ef1b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -123,6 +123,12 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install AWS CLI v2.22.35 + run: | + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.22.35.zip" -o "awscliv2.zip" + unzip awscliv2.zip + sudo ./aws/install --update + - name: Build WC bundle run: | # TODO: Reinitialise when storybook build is fixed diff --git a/CHANGELOG.md b/CHANGELOG.md index 6795ef2bb..43cbee96f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Fixed + +- Fix AWS CLI in deploy script to 2.22.35 to workaround cloudflare issue (See https://developers.cloudflare.com/r2/examples/aws/aws-cli/) (#1158) + ### Added - Ability to write to files in `python` (#1146) From d554021a89f008ca01f80a5a2b61b2af2fdc5400 Mon Sep 17 00:00:00 2001 From: "create-issue-branch[bot]" <53036503+create-issue-branch[bot]@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:00:28 +0000 Subject: [PATCH 05/18] Integration: Save / Login functionality not as expected (#1162) 'Login to save' button now automatically saves after logging in. Closes #843 --------- Co-authored-by: create-issue-branch[bot] <53036503+create-issue-branch[bot]@users.noreply.github.com> Co-authored-by: Dan Halson Co-authored-by: Dan Halson Co-authored-by: Conor --- .devcontainer/devcontainer.json | 8 +------- CHANGELOG.md | 3 ++- src/hooks/useProjectPersistence.js | 18 +++++++++++++----- src/hooks/useProjectPersistence.test.js | 20 ++++++++++++++++++++ 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8f310d6f..a4ce7d334 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -39,8 +39,6 @@ "vscode": { "extensions": [ "ms-azuretools.vscode-docker", - "ninoseki.vscode-gem-lens", - "rebornix.ruby", "eamodio.gitlens", "github.vscode-pull-request-github", "wmaurer.change-case", @@ -53,7 +51,6 @@ "pkosta2005.heroku-command", "yzhang.markdown-all-in-one", "mikestead.dotenv", - "wingrunr21.vscode-ruby", "ms-vscode.remote-repositories", "github.remotehub", "circleci.circleci", @@ -68,10 +65,7 @@ ], "settings": { "terminal.integrated.defaultProfile.linux": "zsh", - "jest.autoRun": { - "watch": false, - "onSave": "test-src-file" - } + "jest.jestCommandLine": "yarn test" } } }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 43cbee96f..c6145d4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- Fix AWS CLI in deploy script to 2.22.35 to workaround cloudflare issue (See https://developers.cloudflare.com/r2/examples/aws/aws-cli/) (#1158) +- Fix AWS CLI in deploy script to 2.22.35 to workaround cloudflare issue (See https://developers.cloudflare.com/r2/examples/aws/aws-cli/) (#1178) ### Added @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - Made `INSTRUCTIONS.md` a reserved file name (#1160) +- Login to save now logs in and automatically saves (#1162) ## [0.28.14] - 2025-01-06 diff --git a/src/hooks/useProjectPersistence.js b/src/hooks/useProjectPersistence.js index 115d9a0f2..f5bc2ca65 100644 --- a/src/hooks/useProjectPersistence.js +++ b/src/hooks/useProjectPersistence.js @@ -42,17 +42,26 @@ export const useProjectPersistence = ({ const accessToken = user?.access_token; const params = { reactAppApiEndpoint, accessToken }; - if (saveTriggered) { + if (saveTriggered || localStorage.getItem("awaitingSave")) { if (isOwner(user, project)) { await dispatch( syncProject("save")({ ...params, project, autosave: false }), ); + localStorage.removeItem("awaitingSave"); } else if (user && identifier) { - await dispatch(syncProject("remix")({ ...params, project })); + await dispatch( + syncProject("remix")({ + ...params, + project, + }), + ); if (loadRemix) { // Ensure the remixed project is loaded, otherwise we'll get in a mess await dispatch( - syncProject("loadRemix")({ ...params, identifier }), + syncProject("loadRemix")({ + ...params, + identifier, + }), ); } } @@ -81,13 +90,12 @@ export const useProjectPersistence = ({ if (justLoaded) { dispatch(expireJustLoaded()); } else { - saveToLocalStorage(project); - if (!hasShownSavePrompt) { user ? showSavePrompt() : showLoginPrompt(); dispatch(setHasShownSavePrompt()); } } + saveToLocalStorage(project); } } }, autoSaveInterval); diff --git a/src/hooks/useProjectPersistence.test.js b/src/hooks/useProjectPersistence.test.js index 0ec078cbc..212710f2c 100644 --- a/src/hooks/useProjectPersistence.test.js +++ b/src/hooks/useProjectPersistence.test.js @@ -342,5 +342,25 @@ describe("When logged in", () => { autosave: false, }); }); + + test("Saves project to database if awaitingSave is set", async () => { + localStorage.setItem("awaitingSave", "true"); + + renderHook(() => + useProjectPersistence({ + user: user1, + project: project, + saveTriggered: false, + }), + ); + jest.runAllTimers(); + expect(saveProject).toHaveBeenCalledWith({ + project, + accessToken: user1.access_token, + autosave: false, + }); + + localStorage.removeItem("awaitingSave"); + }); }); }); From 784e86ab7a36a800622ae716576d2399716ddf89 Mon Sep 17 00:00:00 2001 From: Lois Wells <88904316+loiswells97@users.noreply.github.com> Date: Thu, 30 Jan 2025 09:15:52 +0000 Subject: [PATCH 06/18] HTML audio video support (#1179) ## What's Changed? - `HtmlRunner` now looks for media in `editor.project.videos` and `editor.project.audio` as well as `editor.project.image_list` - Upgraded React Testing Library to reduce test warnings --- CHANGELOG.md | 1 + package.json | 2 +- .../Editor/Runners/HtmlRunner/HtmlRunner.jsx | 34 +++++---- .../Runners/HtmlRunner/HtmlRunner.test.js | 71 +++++++++++++++++++ yarn.lock | 50 ++++++++----- 5 files changed, 127 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6145d4c6..391380a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Ability to write to files in `python` (#1146) - Support for the `outputPanels` attribute in the `PyodideRunner` (#1157) - Downloading project instructions (#1160) +- Support for audio and video files in HTML projects (#1179) ### Changed diff --git a/package.json b/package.json index bbda89f88..9ddc62e48 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "@react-three/test-renderer": "8.2.1", "@svgr/webpack": "5.5.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.2.0", + "@testing-library/react": "14.3.1", "@testing-library/user-event": "^12.1.10", "@typescript-eslint/eslint-plugin": "^4.5.0", "@typescript-eslint/parser": "^4.5.0", diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx index f5bcbd13e..ae8a47720 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx @@ -31,7 +31,11 @@ import { MOBILE_MEDIA_QUERY } from "../../../../utils/mediaQueryBreakpoints"; function HtmlRunner() { const project = useSelector((state) => state.editor.project); const projectCode = project.components; - const projectImages = project.image_list; + const projectMedia = [ + ...(project.image_list || []), + ...(project.audio || []), + ...(project.videos || []), + ]; const firstPanelIndex = 0; const focussedFileIndex = useSelector( @@ -114,9 +118,9 @@ function HtmlRunner() { const cssProjectImgs = (projectFile) => { var updatedProjectFile = { ...projectFile }; if (projectFile.extension === "css") { - projectImages.forEach((image) => { - const find = new RegExp(`['"]${image.filename}['"]`, "g"); // prevent substring matches - const replace = `"${image.url}"`; + projectMedia.forEach((media_file) => { + const find = new RegExp(`['"]${media_file.filename}['"]`, "g"); // prevent substring matches + const replace = `"${media_file.url}"`; updatedProjectFile.content = updatedProjectFile.content.replaceAll( find, replace, @@ -264,27 +268,29 @@ function HtmlRunner() { const replaceSrcNodes = ( indexPage, - projectImages, + projectMedia, projectCode, attr = "src", ) => { const srcNodes = indexPage.querySelectorAll(`[${attr}]`); srcNodes.forEach((srcNode) => { - const projectImage = projectImages.find( + const projectMediaFile = projectMedia.find( (component) => component.filename === srcNode.attrs[attr], ); - const projectFile = projectCode.find( + const projectTextFile = projectCode.find( (file) => `${file.name}.${file.extension}` === srcNode.attrs[attr], ); let src = ""; - if (!!projectImage) { - src = projectImage.url; - } else if (!!projectFile) { + if (!!projectMediaFile) { + src = projectMediaFile.url; + } else if (!!projectTextFile) { src = getBlobURL( - projectFile.content, - mimeTypes.lookup(`${projectFile.name}.${projectFile.extension}`), + projectTextFile.content, + mimeTypes.lookup( + `${projectTextFile.name}.${projectTextFile.extension}`, + ), ); } else if (matchingRegexes(allowedExternalLinks, srcNode.attrs[attr])) { src = srcNode.attrs[attr]; @@ -351,8 +357,8 @@ function HtmlRunner() { body.insertAdjacentHTML("afterbegin", disableLocalStorageScript); replaceHrefNodes(indexPage, projectCode); - replaceSrcNodes(indexPage, projectImages, projectCode); - replaceSrcNodes(indexPage, projectImages, projectCode, "data-src"); + replaceSrcNodes(indexPage, projectMedia, projectCode); + replaceSrcNodes(indexPage, projectMedia, projectCode, "data-src"); body.appendChild(parse(``)); diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js index 98fac8be5..40ad19de9 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js @@ -499,6 +499,77 @@ describe("When an allowed external link is rendered", () => { }); }); +describe("When media is rendered", () => { + const mediaHTML = + '