diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8f310d6f..4c660045d 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", @@ -64,14 +61,13 @@ "avraammavridis.vsc-react-documentation", "ofhumanbondage.react-proptypes-intellisense", "syler.sass-indented", - "codezombiech.gitignore" + "codezombiech.gitignore", + "github.vscode-github-actions", + "humao.rest-client" ], "settings": { "terminal.integrated.defaultProfile.linux": "zsh", - "jest.autoRun": { - "watch": false, - "onSave": "test-src-file" - } + "jest.jestCommandLine": "yarn test" } } }, 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/ci-cd.yml b/.github/workflows/ci-cd.yml index bb741e939..66d19cbdc 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -104,7 +104,7 @@ jobs: REACT_APP_PLAUSIBLE_SOURCE: "" - name: Archive cypress artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4.6.0 if: failure() with: name: cypress-artifacts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index de3b9fe93..22c40ef0f 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 @@ -145,8 +151,18 @@ jobs: REACT_APP_SENTRY_ENV: ${{ inputs.react_app_sentry_env }} - name: Deploy site to S3 bucket - run: aws s3 sync ./build/ s3://${{ secrets.AWS_S3_BUCKET }}/${{ needs.setup-environment.outputs.deploy_dir }} --endpoint ${{ secrets.AWS_ENDPOINT }} + run: aws s3 sync ./build/ s3://${{ secrets.AWS_S3_BUCKET }}/${{ needs.setup-environment.outputs.deploy_dir }} --endpoint ${{ secrets.AWS_ENDPOINT }} env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: ${{ secrets.AWS_REGION }} + + - name: Purge Cloudflare cache + run: | + curl -X POST "https://api.cloudflare.com/client/v4/zones/$%7B%7B%20secrets.CLOUDFLARE_ZONE_ID%20%7D%7D/purge_cache" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + --data '{"files":["${{ needs.setup-environment.outputs.public_url }}/web-component.html", "${{ needs.setup-environment.outputs.public_url }}/web-component.js"]}' + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1db3ed5..b239d5e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.29.0] - 2025-02-06 + +### Added + +- Autosave instructions (#1163) +- Editable instructions (#1161) +- 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) +- Show instructions option in sidebar if instructions are editable (#1164) +- Open instructions panel by default if instructions are editable (#1164) +- Instructions empty state to show when instructions are editable (#1165, #1168) +- Allow `instructions` attribute to override instructions attached to the project (#1169) +- Instructions tabs for edit and viewing (#1167) +- Add remove instructions button modal (#1176, #1191) +- Dark mode colours (#1182) +- Dark mode for instuctions code block (#1187) +- Change markdown links to open in new tab (#1188) +- Update demo instructions text (#1189) +- Syntax highlighting for custom instructions in Code Editor for Education (#1190) + +### Changed + +- Made `INSTRUCTIONS.md` a reserved file name (#1160) +- Clear the redux store when the component unmounts (#1169) +- Login to save now logs in and automatically saves (#1162) +- Instructions panel heading (#1183) +- Added cache purge on deployment (#1186) + +### 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/) (#1178) +- Padding on instructions code block (#1184, 1190) ## [0.28.14] - 2025-01-06 @@ -1027,7 +1060,8 @@ 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.28.14...HEAD +[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.0...HEAD +[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 [0.28.12]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.12 diff --git a/README.md b/README.md index 54281f396..652313d25 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as - `assets_identifier`: Load assets (not code) from this project identifier - `auth_key`: Authenticate the user to allow them to make API requests such as saving their work - `code`: A preset blob of code to show in the editor pane (overrides content of `main.py`/`index.html`) +- `editable_instructions`: Boolean whether to show edit panel for instructions - `embedded`: Enable embedded mode which hides some functionality (defaults to `false`) - `host_styles`: Styles passed into the web component from the host page - `identifier`: Load the project with this identifier from the database 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/package.json b/package.json index bbda89f88..af4712906 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raspberrypifoundation/editor-ui", - "version": "0.28.14", + "version": "0.29.0", "private": true, "dependencies": { "@apollo/client": "^3.7.8", @@ -45,6 +45,7 @@ "js-convert-case": "^4.2.0", "jszip": "^3.10.1", "jszip-utils": "^0.1.0", + "marked": "^15.0.6", "material-symbols": "^0.27.0", "mime-types": "^2.1.35", "node-html-parser": "^6.1.5", @@ -53,6 +54,7 @@ "prismjs": "^1.29.0", "prompts": "2.4.0", "prop-types": "^15.8.1", + "raw-loader": "^4.0.2", "rc-resize-observer": "^1.3.1", "re-resizable": "6.9.9", "react": "^18.1.0", @@ -111,7 +113,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/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/assets/markdown/demoInstructions.md b/src/assets/markdown/demoInstructions.md new file mode 100644 index 000000000..df1b5a6cb --- /dev/null +++ b/src/assets/markdown/demoInstructions.md @@ -0,0 +1,18 @@ +# How instructions work + +## Enabling instructions + +Any text written here will be visible to students in the sidebar + +## Writing instructions + +Write your instructions using [Markdown](https://www.markdownguide.org/) +### What you can do + +Lists: + +- Bullet points +- Bullet points + +1. numbered steps +2. numbered steps diff --git a/src/assets/stylesheets/Instructions.scss b/src/assets/stylesheets/Instructions.scss index e32b73253..82d7b99bf 100644 --- a/src/assets/stylesheets/Instructions.scss +++ b/src/assets/stylesheets/Instructions.scss @@ -1,15 +1,23 @@ -@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/components/squiggle.scss" as *; +@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/components/squiggle.scss" + as *; +@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/mixins/typography"; @use "./rpf_design_system/colours" as *; @use "./rpf_design_system/spacing" as *; -@use './rpf_design_system/font-size' as *; +@use "./rpf_design_system/font-size" as *; @use "./rpf_design_system/font-weight" as *; .project-instructions { + block-size: 100%; + h2 { @include font-size-1-25(bold); margin: 0; } + a { + color: var(--editor-color-theme-secondary); + } + strong { font-weight: $font-weight-bold; } @@ -32,27 +40,33 @@ } } - pre { + code { + color: $rpf-white; background-color: $rpf-grey-700; border-radius: 8px; - padding: $space-0-5; - overflow: auto; - margin: $space-1 0; + padding: calc(0.75 * $space-0-125) $space-0-5; } - code { - color: $rpf-white; + pre { background-color: $rpf-grey-700; + border: 1px solid $rpf-grey-600; border-radius: 8px; - padding: calc(0.75 * $space-0-125) $space-0-5; + padding: $space-0-5 $space-1; + overflow: auto; + margin: $space-1 0; + + code { + padding-inline: 0; + } } .c-project-code { - margin: $space-1 0 $space-1-5 0; - border-radius: 8px; background-color: $rpf-grey-700; + border-radius: 8px; + margin: $space-1 0 $space-1-5 0; pre { + border: none; margin: 0; } @@ -72,14 +86,11 @@ border-block-end: 1px solid $rpf-grey-600; } - - .line-numbers { padding-inline-start: $space-3; padding-inline-end: $space-1; } - .line-numbers-rows { border-color: $rpf-text-secondary-dark; @@ -97,13 +108,37 @@ } .language-python { - .number, .boolean, .function { + .number, + .boolean, + .function { + color: $rpf-syntax-1; + } + .keyword { + color: $rpf-syntax-4; + } + .string, + .char { + color: $rpf-syntax-2; + } + .comment { + color: $rpf-syntax-3; + } + + .keyword-print { + color: $rpf-white; + } + } + + .language-javascript { + .number, + .boolean { color: $rpf-syntax-1; } .keyword { color: $rpf-syntax-4; } - .string, .char { + .string, + .char { color: $rpf-syntax-2; } .comment { @@ -129,7 +164,8 @@ color: $rpf-syntax-4; } - .property, .punctuation { + .property, + .punctuation { color: $rpf-white; } } @@ -137,7 +173,8 @@ .language-html { .tag { color: $rpf-syntax-4; - .punctuation, .attr-name { + .punctuation, + .attr-name { color: $rpf-white; } @@ -255,4 +292,69 @@ white-space: pre-wrap; } } + + .project-instructions__empty { + background-color: var(--editor-color-layer-1); + border-radius: $space-0-5; + display: flex; + flex-direction: column; + gap: $space-1-5; + padding: $space-1; + } + + .project-instructions__empty-text { + margin: 0; + } +} + +.--dark .project-instructions { + pre { + background-color: $rpf-grey-850; + } + code { + background-color: $rpf-grey-850; + } +} + +#app, +#wc { + .c-instruction-tabs { + display: flex; + flex-direction: column; + block-size: 100%; + + .react-tabs { + border: 1px solid var(--editor-color-outline); + + .react-tabs__tab-list { + border-block-end: 1px solid var(--editor-color-outline); + } + } + + .react-tabs__tab { + background: var(--editor-color-tab-background); + padding-inline: var(--space-1-5); + + &--selected { + background: var(--editor-color-layer-3); + } + } + + .react-tabs__tab-panel { + .project-instructions { + padding-inline: var(--space-1); + } + } + + textarea { + @include typography.style-1(); + + background: var(--editor-color-layer-3); + color: var(--editor-color-text); + border: none; + block-size: 100%; + overflow-block: scroll; + padding: var(--space-1); + } + } } diff --git a/src/assets/stylesheets/InternalStyles.scss b/src/assets/stylesheets/InternalStyles.scss index 2f24fccd6..189405bf0 100644 --- a/src/assets/stylesheets/InternalStyles.scss +++ b/src/assets/stylesheets/InternalStyles.scss @@ -103,6 +103,7 @@ button:focus-visible { --editor-color-theme-tertiary: #{$rpf-teal-100}; --editor-color-text: #{$rpf-text-primary}; --editor-color-text-secondary: #{$rpf-text-secondary}; + --editor-color-tab-background: #{$rpf-off-white}; } .--dark { @@ -116,6 +117,7 @@ button:focus-visible { --editor-color-text: #{$rpf-white}; --editor-color-text-secondary: #{$rpf-text-secondary-darkmode}; --rpf-button-secondary-text-color: #{$rpf-white}; + --editor-color-tab-background: #{$rpf-grey-700}; .rpf-button--secondary { border-color: $rpf-navy-800;} 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/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/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 = + '