diff --git a/src/components/ProjectBar/ScratchProjectBar.test.jsx b/src/components/ProjectBar/ScratchProjectBar.test.jsx
index 85840103e..f63506abb 100644
--- a/src/components/ProjectBar/ScratchProjectBar.test.jsx
+++ b/src/components/ProjectBar/ScratchProjectBar.test.jsx
@@ -296,3 +296,61 @@ 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, 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();
+ 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/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
;
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", () => {
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/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);
+ });
+});
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");
+ });
+});
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 };
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",