From 6bdb2b15147ab3d0391bb17d527e3760b52d10eb Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 13:31:41 +0200 Subject: [PATCH 01/10] feat: add preview flag to editor redux state --- src/redux/EditorSlice.js | 5 +++++ src/redux/EditorSlice.test.js | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index 0edad379e..cae45952e 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -82,6 +82,7 @@ export const editorInitialState = { project: {}, cascadeUpdate: false, readOnly: false, + preview: false, saveTriggered: false, saving: "idle", loading: "idle", @@ -279,6 +280,9 @@ const EditorSlice = createSlice({ setReadOnly: (state, action) => { state.readOnly = action.payload; }, + setPreview: (state, action) => { + state.preview = action.payload; + }, setInstructionsEditable: (state, action) => { state.instructionsEditable = action.payload; }, @@ -520,6 +524,7 @@ export const { scratchSaveFailed, setProjectInstructions, setReadOnly, + setPreview, setInstructionsEditable, setSenseHatAlwaysEnabled, setOfflineEnabled, diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index ec785f862..874a278ba 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -11,6 +11,7 @@ import reducer, { setIsOutputOnly, setErrorDetails, setReadOnly, + setPreview, setOfflineEnabled, addProjectComponent, updateProjectComponent, @@ -128,6 +129,12 @@ test("Action setReadOnly correctly sets readOnly", () => { expect(reducer(previousState, setReadOnly(true))).toEqual(expectedState); }); +test("Action setPreview correctly sets preview", () => { + const previousState = { preview: false }; + const expectedState = { preview: true }; + expect(reducer(previousState, setPreview(true))).toEqual(expectedState); +}); + test("Action setOfflineEnabled correctly sets offlineEnabled", () => { const previousState = { offlineEnabled: false }; const expectedState = { offlineEnabled: true }; From ac5344131537b7b1b397f84dd17625f7adb11b07 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 13:50:19 +0200 Subject: [PATCH 02/10] feat: support preview mode in Scratch project bar --- .../ProjectBar/ScratchProjectBar.jsx | 7 ++- .../ProjectBar/ScratchProjectBar.test.jsx | 55 +++++++++++++++++++ src/hooks/usePreviewMode.js | 10 ++++ src/hooks/usePreviewMode.test.js | 23 ++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/hooks/usePreviewMode.js create mode 100644 src/hooks/usePreviewMode.test.js diff --git a/src/components/ProjectBar/ScratchProjectBar.jsx b/src/components/ProjectBar/ScratchProjectBar.jsx index 20dfddbd6..275310768 100644 --- a/src/components/ProjectBar/ScratchProjectBar.jsx +++ b/src/components/ProjectBar/ScratchProjectBar.jsx @@ -13,6 +13,7 @@ import SaveStatus from "../SaveStatus/SaveStatus"; import "../../assets/stylesheets/ProjectBar.scss?inline"; import { setScratchLastSavedTime } from "../../redux/EditorSlice"; import { useScratchSave } from "../../hooks/useScratchSave"; +import { usePreviewMode } from "../../hooks/usePreviewMode"; const getProjectLastSavedTime = (updatedAt) => { const timestamp = Date.parse(updatedAt || ""); @@ -27,9 +28,11 @@ const ScratchProjectBar = ({ nameEditable = true }) => { const loading = useSelector((state) => state.editor.loading); const project = useSelector((state) => state.editor.project); const readOnly = useSelector((state) => state.editor.readOnly); + const previewMode = usePreviewMode(); const saving = useSelector((state) => state.editor.saving); const lastSavedTime = useSelector((state) => state.editor.lastSavedTime); - const canSave = Boolean(user && !readOnly); + const canSave = Boolean(user && !readOnly && !previewMode); + const canEditProjectName = !readOnly && !previewMode && nameEditable; const { saveScratchProject, shouldRemixOnSave } = useScratchSave({ enabled: canSave, }); @@ -62,7 +65,7 @@ const ScratchProjectBar = ({ nameEditable = true }) => { return (
- +
{!readOnly && (
diff --git a/src/components/ProjectBar/ScratchProjectBar.test.jsx b/src/components/ProjectBar/ScratchProjectBar.test.jsx index 85840103e..4a3c44dd3 100644 --- a/src/components/ProjectBar/ScratchProjectBar.test.jsx +++ b/src/components/ProjectBar/ScratchProjectBar.test.jsx @@ -296,3 +296,58 @@ describe("Additional Scratch manual save states", () => { expect(screen.queryByText("header.loginToSave")).not.toBeInTheDocument(); }); }); + +describe("When preview mode", () => { + test("shows upload and download but not save", () => { + renderSignedInScratchProjectBar({ + editor: { + preview: true, + }, + }); + + expect( + screen.getByRole("button", { name: "header.upload" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "header.download" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "header.save" }), + ).not.toBeInTheDocument(); + }); + + test("does not auto-save after a Scratch project change", () => { + renderSignedInScratchProjectBar({ + editor: { + preview: true, + }, + }); + + dispatchScratchMessage("scratch-gui-project-changed"); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(postMessageToScratchIframe).not.toHaveBeenCalled(); + }); + + test("read only overrides preview and hides upload", () => { + renderSignedInScratchProjectBar({ + editor: { + preview: true, + readOnly: true, + }, + }); + + expect( + screen.queryByRole("button", { name: "header.upload" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "header.download" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "header.save" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/hooks/usePreviewMode.js b/src/hooks/usePreviewMode.js new file mode 100644 index 000000000..38b3dcd1f --- /dev/null +++ b/src/hooks/usePreviewMode.js @@ -0,0 +1,10 @@ +import { useSelector } from "react-redux"; + +/** + * Preview mode is active only when `preview` is on and `readOnly` is off. + * If both are set, `readOnly` wins and this returns false. + */ +export const selectPreviewMode = (state) => + Boolean(state.editor?.preview && !state.editor?.readOnly); + +export const usePreviewMode = () => useSelector(selectPreviewMode); diff --git a/src/hooks/usePreviewMode.test.js b/src/hooks/usePreviewMode.test.js new file mode 100644 index 000000000..301da78ed --- /dev/null +++ b/src/hooks/usePreviewMode.test.js @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { selectPreviewMode } from "./usePreviewMode"; + +describe("selectPreviewMode", () => { + test("returns true when preview is on and readOnly is off", () => { + expect( + selectPreviewMode({ editor: { preview: true, readOnly: false } }), + ).toBe(true); + }); + + test("returns false when preview is off", () => { + expect( + selectPreviewMode({ editor: { preview: false, readOnly: false } }), + ).toBe(false); + }); + + test("returns false when readOnly overrides preview", () => { + expect( + selectPreviewMode({ editor: { preview: true, readOnly: true } }), + ).toBe(false); + }); +}); From fb3472ff09d580cf98999e9d996e4b2c9da87360 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 14:05:30 +0200 Subject: [PATCH 03/10] feat: expose preview attribute on the editor web component --- src/containers/WebComponentLoader.jsx | 6 ++++++ src/containers/WebComponentLoader.test.jsx | 22 ++++++++++++++++++++++ src/web-component.jsx | 2 ++ 3 files changed, 30 insertions(+) diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index b125cecb4..e8c2d10b5 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -9,6 +9,7 @@ import { setReactAppApiEndpoint, setScratchApiEndpoint, setReadOnly, + setPreview, } from "../redux/EditorSlice"; import WebComponentProject from "../components/WebComponentProject/WebComponentProject"; import { useTranslation } from "react-i18next"; @@ -62,6 +63,7 @@ const WebComponentLoader = (props) => { projectNameEditable = false, reactAppApiEndpoint = process.env.REACT_APP_API_ENDPOINT, scratchApiEndpoint = process.env.REACT_APP_API_ENDPOINT, + preview = false, readOnly = false, senseHatAlwaysEnabled = false, friendlyErrorsEnabled = false, @@ -201,6 +203,10 @@ const WebComponentLoader = (props) => { dispatch(setReadOnly(readOnly)); }, [readOnly, dispatch]); + useEffect(() => { + dispatch(setPreview(preview)); + }, [preview, dispatch]); + useEffect(() => { dispatch(setOfflineEnabled(offlineEnabled)); }, [offlineEnabled, dispatch]); diff --git a/src/containers/WebComponentLoader.test.jsx b/src/containers/WebComponentLoader.test.jsx index 2564b066f..c3dc8d7e5 100644 --- a/src/containers/WebComponentLoader.test.jsx +++ b/src/containers/WebComponentLoader.test.jsx @@ -9,6 +9,7 @@ import { disableTheming, editorInitialState, setReadOnly, + setPreview, setSenseHatAlwaysEnabled, setReactAppApiEndpoint, setScratchApiEndpoint, @@ -311,6 +312,27 @@ describe("When no user is in state", () => { ); }); + test("Sets the preview state correctly", () => { + const { unmount } = render( + + + + + , + ); + + expect(store.getActions()).toEqual( + expect.arrayContaining([setPreview(true)]), + ); + unmount(); + }); + test("Disables theming", () => { expect(store.getActions()).toEqual( expect.arrayContaining([disableTheming()]), diff --git a/src/web-component.jsx b/src/web-component.jsx index b109a6216..46074c230 100644 --- a/src/web-component.jsx +++ b/src/web-component.jsx @@ -74,6 +74,7 @@ class WebComponent extends HTMLElement { "project_name_editable", "react_app_api_endpoint", "scratch_api_endpoint", + "preview", "read_only", "sense_hat_always_enabled", "show_save_prompt", @@ -96,6 +97,7 @@ class WebComponent extends HTMLElement { "output_only", "output_split_view", "project_name_editable", + "preview", "read_only", "sense_hat_always_enabled", "show_save_prompt", From ec7bed10f698983263547151866beabb2b8f2d43 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 14:05:31 +0200 Subject: [PATCH 04/10] docs: document the preview web component attribute --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b0a1effce..8d1e73850 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as - `output_panels`: Array of output panel names to display (defaults to `["text", "visual"]`) - `output_split_view`: Start with split view in output panel (defaults to `false`, i.e. tabbed view) - `project_name_editable`: Allow the user to edit the project name in the project bar (defaults to `false`) +- `preview`: Enable preview mode for Scratch projects — upload and download stay available, but saving is disabled (defaults to `false`). If `read_only` is also set, read-only behaviour takes precedence and upload is hidden. - `react_app_api_endpoint`: API endpoint to send project-related requests to - `offline_enabled`: Show an offline indicator when the user's device loses connectivity (defaults to `false`). Requires the host page's service worker to broadcast `{ type: "OFFLINE" }` / `{ type: "ONLINE" }` messages - see [Offline support](#offline-support). - `read_only`: Display the editor in read only mode (defaults to `false`) From 8c7547ab2cc9c6bbe6227685997bea7c1fd5e892 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 16:23:31 +0200 Subject: [PATCH 05/10] feat: address saving comment on PR review --- src/components/ProjectBar/ProjectBar.jsx | 9 ++++++--- src/components/SaveButton/SaveButton.jsx | 12 +++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/ProjectBar/ProjectBar.jsx b/src/components/ProjectBar/ProjectBar.jsx index dd5adb151..febda21f6 100644 --- a/src/components/ProjectBar/ProjectBar.jsx +++ b/src/components/ProjectBar/ProjectBar.jsx @@ -8,6 +8,7 @@ import ProjectName from "../ProjectName/ProjectName"; import DownloadButton from "../DownloadButton/DownloadButton"; import SaveButton from "../SaveButton/SaveButton"; import useIsOnline from "../../hooks/useIsOnline"; +import { usePreviewMode } from "../../hooks/usePreviewMode"; import "../../assets/stylesheets/ProjectBar.scss?inline"; import { isOwner } from "../../utils/projectHelpers"; @@ -21,6 +22,8 @@ const ProjectBar = ({ nameEditable = true }) => { const offlineEnabled = useSelector((state) => state.editor.offlineEnabled); const projectOwner = isOwner(user, project); const readOnly = useSelector((state) => state.editor.readOnly); + const previewMode = usePreviewMode(); + const canSave = !readOnly && !previewMode; const isOnline = useIsOnline(); if (loading !== "success") { @@ -29,7 +32,7 @@ const ProjectBar = ({ nameEditable = true }) => { return (
- +
{ type="tertiary" />
- {!projectOwner && !readOnly && ( + {!projectOwner && canSave && (
)} {user && - !readOnly && + canSave && (offlineEnabled && !isOnline ? projectOwner && (
diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx index e77b4a24a..360b3928c 100644 --- a/src/components/SaveButton/SaveButton.jsx +++ b/src/components/SaveButton/SaveButton.jsx @@ -10,6 +10,7 @@ import OfflineBadge from "../OfflineBadge/OfflineBadge"; import SaveIcon from "../../assets/icons/save.svg"; import { triggerSave } from "../../redux/EditorSlice"; import useIsOnline from "../../hooks/useIsOnline"; +import { usePreviewMode } from "../../hooks/usePreviewMode"; const SaveButton = ({ className, type, fill = false }) => { const dispatch = useDispatch(); @@ -21,6 +22,8 @@ const SaveButton = ({ className, type, fill = false }) => { const user = useSelector((state) => state.auth.user); const project = useSelector((state) => state.editor.project); const offlineEnabled = useSelector((state) => state.editor.offlineEnabled); + const readOnly = useSelector((state) => state.editor.readOnly); + const previewMode = usePreviewMode(); const isOnline = useIsOnline(); useEffect(() => { @@ -39,7 +42,14 @@ const SaveButton = ({ className, type, fill = false }) => { const projectOwner = isOwner(user, project); - if (loading !== "success" || projectOwner || !buttonType) return null; + if ( + loading !== "success" || + projectOwner || + !buttonType || + readOnly || + previewMode + ) + return null; if (offlineEnabled && !isOnline) { return ; From 90b5ef14d355683b19b890295829ac68d2fdbb24 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 16:26:38 +0200 Subject: [PATCH 06/10] test: cover preview save gating and document rename behaviour --- src/components/ProjectBar/ProjectBar.test.jsx | 32 +++++++++ src/components/SaveButton/SaveButton.test.jsx | 71 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/components/ProjectBar/ProjectBar.test.jsx b/src/components/ProjectBar/ProjectBar.test.jsx index bd37f068f..f544e6eed 100644 --- a/src/components/ProjectBar/ProjectBar.test.jsx +++ b/src/components/ProjectBar/ProjectBar.test.jsx @@ -259,3 +259,35 @@ describe("When read only", () => { expect(screen.queryByText(/saveStatus.saved/)).not.toBeInTheDocument(); }); }); + +describe("When preview mode", () => { + beforeEach(() => { + renderProjectBar({ + editor: { + project, + preview: true, + readOnly: false, + lastSavedTime: new Date().getTime(), + }, + auth: { + user, + }, + }); + }); + + test("Project name is not editable", () => { + expect(screen.queryByTitle("header.renameProject")).not.toBeInTheDocument(); + }); + + test("Save button is not shown", () => { + expect(screen.queryByText("header.save")).not.toBeInTheDocument(); + }); + + test("Save status is not shown", () => { + expect(screen.queryByText(/saveStatus.saved/)).not.toBeInTheDocument(); + }); + + test("Download button remains available", () => { + expect(screen.queryByText("header.download")).toBeInTheDocument(); + }); +}); diff --git a/src/components/SaveButton/SaveButton.test.jsx b/src/components/SaveButton/SaveButton.test.jsx index ccad526cb..a59769a18 100644 --- a/src/components/SaveButton/SaveButton.test.jsx +++ b/src/components/SaveButton/SaveButton.test.jsx @@ -113,6 +113,77 @@ describe("When project is loaded", () => { ).not.toBeInTheDocument(); }); }); + + describe("in preview mode", () => { + beforeEach(() => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + loading: "success", + webComponent: true, + preview: true, + readOnly: false, + project: { + identifier: "hot-diggity-dog", + user_id: "some-other-user", + }, + }, + auth: { + user: { + profile: { + user: "some-dummy-user", + }, + }, + }, + }; + store = mockStore(initialState); + render( + + + , + ); + }); + + test("Does not render save button", () => { + expect(screen.queryByText("header.save")).not.toBeInTheDocument(); + }); + }); + + describe("in read only mode", () => { + beforeEach(() => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + loading: "success", + webComponent: true, + readOnly: true, + project: { + identifier: "hot-diggity-dog", + user_id: "some-other-user", + }, + }, + auth: { + user: { + profile: { + user: "some-dummy-user", + }, + }, + }, + }; + store = mockStore(initialState); + render( + + + , + ); + }); + + test("Does not render save button", () => { + expect(screen.queryByText("header.save")).not.toBeInTheDocument(); + }); + }); }); describe("Without a logged in user", () => { From d844e82fa74beec0daace2d00f072eda2b75b07b Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 16:26:53 +0200 Subject: [PATCH 07/10] docs: note preview disables renaming and cover save gating in tests --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d1e73850..0d701b1a2 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as - `output_panels`: Array of output panel names to display (defaults to `["text", "visual"]`) - `output_split_view`: Start with split view in output panel (defaults to `false`, i.e. tabbed view) - `project_name_editable`: Allow the user to edit the project name in the project bar (defaults to `false`) -- `preview`: Enable preview mode for Scratch projects — upload and download stay available, but saving is disabled (defaults to `false`). If `read_only` is also set, read-only behaviour takes precedence and upload is hidden. +- `preview`: Enable preview mode for Scratch projects — upload and download stay available, but saving and renaming are disabled (defaults to `false`). If `read_only` is also set, read-only behaviour takes precedence and upload is hidden. - `react_app_api_endpoint`: API endpoint to send project-related requests to - `offline_enabled`: Show an offline indicator when the user's device loses connectivity (defaults to `false`). Requires the host page's service worker to broadcast `{ type: "OFFLINE" }` / `{ type: "ONLINE" }` messages - see [Offline support](#offline-support). - `read_only`: Display the editor in read only mode (defaults to `false`) From b5e8a885b3217b6281f1ecd586179058a17f2f55 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 25 Aug 2026 16:28:37 +0200 Subject: [PATCH 08/10] test: assert preview mode keeps Scratch project name non-editable --- src/components/ProjectBar/ScratchProjectBar.test.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ProjectBar/ScratchProjectBar.test.jsx b/src/components/ProjectBar/ScratchProjectBar.test.jsx index 4a3c44dd3..f63506abb 100644 --- a/src/components/ProjectBar/ScratchProjectBar.test.jsx +++ b/src/components/ProjectBar/ScratchProjectBar.test.jsx @@ -298,13 +298,16 @@ describe("Additional Scratch manual save states", () => { }); describe("When preview mode", () => { - test("shows upload and download but not save", () => { + test("shows upload and download but not save, and project name is not editable", () => { renderSignedInScratchProjectBar({ editor: { preview: true, }, }); + expect( + screen.queryByRole("button", { name: "header.renameProject" }), + ).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "header.upload" }), ).toBeInTheDocument(); From c8adb791c67812eca0037008ec16d8eb3049dfa6 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 26 Aug 2026 12:05:20 +0200 Subject: [PATCH 09/10] fix: skip autosave, backup, and manual save when preview is on --- README.md | 2 +- src/hooks/useProjectPersistence.js | 69 +++++++++++------- src/hooks/useProjectPersistence.test.js | 97 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0d701b1a2..d5f19eb20 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ The `editor-wc` tag accepts the following attributes, which must be provided as - `output_panels`: Array of output panel names to display (defaults to `["text", "visual"]`) - `output_split_view`: Start with split view in output panel (defaults to `false`, i.e. tabbed view) - `project_name_editable`: Allow the user to edit the project name in the project bar (defaults to `false`) -- `preview`: Enable preview mode for Scratch projects — upload and download stay available, but saving and renaming are disabled (defaults to `false`). If `read_only` is also set, read-only behaviour takes precedence and upload is hidden. +- `preview`: Enable preview mode (Python, HTML, and Scratch) — upload and download stay available, but saving, renaming, autosave, remix, and local backup are disabled (defaults to `false`). If `read_only` is also set, read-only behaviour takes precedence and upload is hidden. - `react_app_api_endpoint`: API endpoint to send project-related requests to - `offline_enabled`: Show an offline indicator when the user's device loses connectivity (defaults to `false`). Requires the host page's service worker to broadcast `{ type: "OFFLINE" }` / `{ type: "ONLINE" }` messages - see [Offline support](#offline-support). - `read_only`: Display the editor in read only mode (defaults to `false`) diff --git a/src/hooks/useProjectPersistence.js b/src/hooks/useProjectPersistence.js index 6a645f46c..d697b860d 100644 --- a/src/hooks/useProjectPersistence.js +++ b/src/hooks/useProjectPersistence.js @@ -5,16 +5,19 @@ import { syncProject } from "../redux/EditorSlice"; import { isEligibleForAutoSave } from "../utils/save/autoSaveLogic"; import { useAutoSave } from "./useAutoSave"; import { useLocalProjectBackup } from "./useLocalProjectBackup"; +import { usePreviewMode } from "./usePreviewMode"; /** * Project persistence orchestration. * - * On edit (debounced, automatic) — exactly one path is active: + * On edit (debounced, automatic) — at most one path is active: + * - neither, when preview mode is on (previewed projects do not persist). * - useAutoSave when canAutoSave (logged in as author, saved project). * - useLocalProjectBackup when !canAutoSave (not logged in, someone else's project, * or author with no identifier yet). * - * On explicit Save (saveTriggered / awaitingSave): manual save or remix via syncProject. + * On explicit Save (saveTriggered / awaitingSave): manual save or remix via syncProject, + * skipped in preview mode. */ export const useProjectPersistence = ({ user, @@ -26,8 +29,10 @@ export const useProjectPersistence = ({ loadRemix = true, }) => { const dispatch = useDispatch(); + const previewMode = usePreviewMode(); + const persistEnabled = !previewMode; - const canAutoSave = isEligibleForAutoSave(user, project); + const canAutoSave = persistEnabled && isEligibleForAutoSave(user, project); useAutoSave({ enabled: canAutoSave, @@ -38,7 +43,7 @@ export const useProjectPersistence = ({ }); useLocalProjectBackup({ - enabled: !canAutoSave, + enabled: persistEnabled && !canAutoSave, user, project, justLoaded, @@ -47,37 +52,47 @@ export const useProjectPersistence = ({ useEffect(() => { const saveProject = async () => { - if (Object.keys(project).length !== 0) { - const identifier = project?.identifier; - const accessToken = user?.access_token; - const params = { reactAppApiEndpoint, accessToken }; + if (previewMode || Object.keys(project).length === 0) { + return; + } - if (saveTriggered || localStorage.getItem("awaitingSave")) { - if (isOwner(user, project)) { - await dispatch( - syncProject("save")({ ...params, project, autosave: false }), - ); - localStorage.removeItem("awaitingSave"); - } else if (user && identifier) { + const identifier = project?.identifier; + const accessToken = user?.access_token; + const params = { reactAppApiEndpoint, accessToken }; + + 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, + }), + ); + if (loadRemix) { + // Ensure the remixed project is loaded, otherwise we'll get in a mess await dispatch( - syncProject("remix")({ + syncProject("loadRemix")({ ...params, - project, + identifier, }), ); - if (loadRemix) { - // Ensure the remixed project is loaded, otherwise we'll get in a mess - await dispatch( - syncProject("loadRemix")({ - ...params, - identifier, - }), - ); - } } } } }; saveProject(); - }, [saveTriggered, project, user, dispatch, reactAppApiEndpoint, loadRemix]); + }, [ + previewMode, + saveTriggered, + project, + user, + dispatch, + reactAppApiEndpoint, + loadRemix, + ]); }; diff --git a/src/hooks/useProjectPersistence.test.js b/src/hooks/useProjectPersistence.test.js index d3c72ae09..b66b5e369 100644 --- a/src/hooks/useProjectPersistence.test.js +++ b/src/hooks/useProjectPersistence.test.js @@ -12,6 +12,8 @@ let mockInitialProjectName = undefined; let mockInitialProjectInstructions = undefined; let mockSaving = "idle"; let mockCodeRunInProgress = false; +let mockPreview = false; +let mockReadOnly = false; let mockDispatch; vi.mock("react-redux", async () => ({ @@ -25,6 +27,8 @@ vi.mock("react-redux", async () => ({ initialProjectInstructions: mockInitialProjectInstructions, saving: mockSaving, codeRunInProgress: mockCodeRunInProgress, + preview: mockPreview, + readOnly: mockReadOnly, }, }), })); @@ -113,6 +117,8 @@ beforeEach(() => { mockInitialProjectInstructions = project.instructions ?? null; mockSaving = "idle"; mockCodeRunInProgress = false; + mockPreview = false; + mockReadOnly = false; mockDispatch = createAsyncThunkDispatchMock(); }); @@ -122,6 +128,8 @@ afterEach(() => { mockInitialProjectInstructions = undefined; mockSaving = "idle"; mockCodeRunInProgress = false; + mockPreview = false; + mockReadOnly = false; localStorage.clear(); }); @@ -566,3 +574,92 @@ describe("When logged in", () => { }); }); }); + +describe("When preview mode", () => { + beforeEach(() => { + mockPreview = true; + mockReadOnly = false; + syncProject.mockImplementation(vi.fn((_) => saveProject)); + }); + + test("Does not autosave an owned changed project", () => { + renderHook(() => + useProjectPersistence({ + user: user1, + project: editedProject, + saveTriggered: false, + }), + ); + vi.runAllTimers(); + expect(saveProject).not.toHaveBeenCalled(); + }); + + test("Does not write a local backup or prompt when viewing someone else's project", () => { + renderHook(() => + useProjectPersistence({ + user: user2, + project: editedProject, + justLoaded: false, + }), + ); + vi.runAllTimers(); + expect(localStorage.getItem("hello-world-project")).toBeNull(); + expect(showSavePrompt).not.toHaveBeenCalled(); + }); + + test("Does not write a local backup or prompt when logged out", () => { + renderHook(() => + useProjectPersistence({ + user: null, + project: editedProject, + justLoaded: false, + }), + ); + vi.runAllTimers(); + expect(localStorage.getItem("hello-world-project")).toBeNull(); + expect(showLoginPrompt).not.toHaveBeenCalled(); + }); + + test("Does not remix when save is triggered on someone else's project", async () => { + syncProject.mockImplementationOnce(vi.fn((_) => remixProject)); + syncProject.mockImplementationOnce(vi.fn((_) => loadProject)); + + renderHook(() => + useProjectPersistence({ + user: user2, + project: project, + saveTriggered: true, + }), + ); + vi.runAllTimers(); + expect(remixProject).not.toHaveBeenCalled(); + expect(loadProject).not.toHaveBeenCalled(); + }); + + test("Does not save when save is triggered on an owned project", async () => { + renderHook(() => + useProjectPersistence({ + user: user1, + project: project, + saveTriggered: true, + }), + ); + vi.runAllTimers(); + expect(saveProject).not.toHaveBeenCalled(); + }); + + test("Does not save when awaitingSave is set", async () => { + localStorage.setItem("awaitingSave", "true"); + + renderHook(() => + useProjectPersistence({ + user: user1, + project: project, + saveTriggered: false, + }), + ); + vi.runAllTimers(); + expect(saveProject).not.toHaveBeenCalled(); + expect(localStorage.getItem("awaitingSave")).toBe("true"); + }); +}); From 110ebfcb8dedd29d2b8a7566977d56b5c28a374e Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 26 Aug 2026 12:13:19 +0200 Subject: [PATCH 10/10] fix: prevent project rename in sidebar projects panel during preview --- .../Sidebar/ProjectsPanel/ProjectsPanel.jsx | 5 ++++- .../ProjectsPanel/ProjectsPanel.test.jsx | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.jsx b/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.jsx index 5c893aa48..827cfe33d 100644 --- a/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.jsx +++ b/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.jsx @@ -14,6 +14,7 @@ import { useMediaQuery } from "react-responsive"; import SaveStatus from "../../../SaveStatus/SaveStatus"; import { navigateToProjectsPageEvent } from "../../../../events/WebComponentCustomEvents"; import DesignSystemButton from "../../../DesignSystemButton/DesignSystemButton"; +import { usePreviewMode } from "../../../../hooks/usePreviewMode"; const ProjectsPanel = () => { const { t } = useTranslation(); @@ -21,6 +22,8 @@ const ProjectsPanel = () => { const isLoggedIn = useSelector((state) => state?.auth?.user); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); const readOnly = useSelector((state) => state.editor.readOnly); + const previewMode = usePreviewMode(); + const canEditProjectName = !readOnly && !previewMode; const saveOptions = ( <> @@ -60,7 +63,7 @@ const ProjectsPanel = () => {
diff --git a/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx b/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx index 9ab640af8..c808ce34b 100644 --- a/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx +++ b/src/components/Menus/Sidebar/ProjectsPanel/ProjectsPanel.test.jsx @@ -115,4 +115,22 @@ describe("Projects Panel", () => { ).not.toBeInTheDocument(); }); }); + + describe("When preview mode", () => { + beforeEach(() => { + renderProjectsPanel({ + editor: { + ...initialState.editor, + preview: true, + readOnly: false, + }, + }); + }); + + test("Project name is not editable", () => { + expect( + screen.queryByTitle("header.renameProject"), + ).not.toBeInTheDocument(); + }); + }); });