Skip to content

Commit e3e8eba

Browse files
authored
Mimic autosave in Scratch Editor (#1469)
This change adds autosave for Scratch projects and keeps the behaviour close to the existing Python and HTML editors. Python and HTML already autosave after a short delay when the user owns a saved project. Scratch is different because project changes happen inside the Scratch iframe, so we listen to Scratch VM change events and pass those changes back to the main app. Autosave only starts once the Scratch project has been saved or remixed and has an identifier. New Scratch projects, and projects that need to be remixed first, still show the manual Save button. After that first save/remix, the manual Save button is hidden and the header shows the autosave status instead. The autosave delay is 2 seconds. If a save is already running, we do not start another one immediately; we queue the next autosave until the current save finishes. Scratch save events now update the shared Redux editor save state: saving, lastSavedTime, and lastSaveAutosave. This is the same state used by the existing save status UI, so Scratch can reuse SaveStatus instead of keeping a separate local save-status flow in the Scratch hook. The Scratch save/remix tests have also been updated to cover the new flow: first save/remix, identifier update, hiding the manual Save button, and triggering autosave once the project is eligible. As Teacher: https://github.com/user-attachments/assets/ee9010bc-0ae8-4cb7-953e-eeaea70253ec As Student (first time): https://github.com/user-attachments/assets/60bb464b-6bb6-4fc9-9131-d9c84994496d
1 parent 44ade45 commit e3e8eba

10 files changed

Lines changed: 710 additions & 197 deletions

cypress/e2e/spec-scratch.cy.js

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe("Scratch save integration", () => {
9797
cy.findByText("cool-scratch").click();
9898
});
9999

100-
it("remixes on the first save, keeps the iframe project loaded, and saves after the identifier update", () => {
100+
it("remixes on the first save, keeps the iframe project loaded, and auto-saves after the identifier update", () => {
101101
getEditorShadow()
102102
.find("iframe[title='Scratch']")
103103
.its("0.contentDocument.body")
@@ -131,6 +131,14 @@ describe("Scratch save integration", () => {
131131
},
132132
}),
133133
);
134+
win.dispatchEvent(
135+
new win.MessageEvent("message", {
136+
origin: win.location.origin,
137+
data: {
138+
type: "scratch-gui-remixing-succeeded",
139+
},
140+
}),
141+
);
134142
});
135143

136144
cy.get("#project-identifier").should("have.text", "student-remix");
@@ -146,11 +154,35 @@ describe("Scratch save integration", () => {
146154
postMessage.resetHistory();
147155
});
148156

149-
getEditorShadow().findByRole("button", { name: "Save" }).click();
157+
getEditorShadow()
158+
.find("button")
159+
.should(($buttons) => {
160+
const buttonText = [...$buttons].map((button) =>
161+
button.textContent.trim(),
162+
);
163+
expect(buttonText).not.to.include("Save");
164+
});
150165

151-
cy.get("@scratchPostMessage")
152-
.its("firstCall.args.0")
153-
.should("deep.include", { type: "scratch-gui-save" });
166+
cy.window().then((win) => {
167+
win.dispatchEvent(
168+
new win.MessageEvent("message", {
169+
origin: win.location.origin,
170+
data: {
171+
type: "scratch-gui-project-changed",
172+
},
173+
}),
174+
);
175+
});
176+
177+
cy.wait(2100);
178+
179+
cy.get("@scratchPostMessage").should((postMessage) => {
180+
const saveMessage = postMessage
181+
.getCalls()
182+
.some((call) => call.args[0]?.type === "scratch-gui-save");
183+
184+
expect(saveMessage).to.eq(true);
185+
});
154186
});
155187
});
156188

src/components/ProjectBar/ScratchProjectBar.jsx

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React from "react";
2-
import { useSelector } from "react-redux";
1+
import React, { useEffect } from "react";
2+
import { useDispatch, useSelector } from "react-redux";
33
import { useTranslation } from "react-i18next";
44
import DownloadIcon from "../../assets/icons/download.svg";
55
import UploadIcon from "../../assets/icons/upload.svg";
@@ -8,26 +8,53 @@ import ProjectName from "../ProjectName/ProjectName";
88
import DownloadButton from "../DownloadButton/DownloadButton";
99
import UploadButton from "../UploadButton/UploadButton";
1010
import DesignSystemButton from "../DesignSystemButton/DesignSystemButton";
11+
import SaveStatus from "../SaveStatus/SaveStatus";
1112

1213
import "../../assets/stylesheets/ProjectBar.scss";
14+
import { setScratchLastSavedTime } from "../../redux/EditorSlice";
1315
import { useScratchSave } from "../../hooks/useScratchSave";
1416

17+
const getProjectLastSavedTime = (updatedAt) => {
18+
const timestamp = Date.parse(updatedAt || "");
19+
return Number.isNaN(timestamp) ? null : timestamp;
20+
};
21+
1522
const ScratchProjectBar = ({ nameEditable = true }) => {
1623
const { t } = useTranslation();
24+
const dispatch = useDispatch();
1725

1826
const user = useSelector((state) => state.auth.user);
1927
const loading = useSelector((state) => state.editor.loading);
28+
const project = useSelector((state) => state.editor.project);
2029
const readOnly = useSelector((state) => state.editor.readOnly);
21-
const showScratchSaveButton = Boolean(user && !readOnly);
22-
const {
23-
isScratchSaving,
24-
saveScratchProject,
25-
scratchSaveLabelKey,
26-
shouldRemixOnSave,
27-
} = useScratchSave({
28-
enabled: showScratchSaveButton,
30+
const saving = useSelector((state) => state.editor.saving);
31+
const lastSavedTime = useSelector((state) => state.editor.lastSavedTime);
32+
const canSave = Boolean(user && !readOnly);
33+
const { saveScratchProject, shouldRemixOnSave } = useScratchSave({
34+
enabled: canSave,
2935
});
30-
const scratchSaveLabel = t(scratchSaveLabelKey);
36+
37+
const projectIdentifier = project?.identifier;
38+
const isScratchSaving = saving === "pending";
39+
const isScratchSaveFailed = saving === "failed";
40+
const isNewProject = !projectIdentifier;
41+
const canAutoSave = Boolean(projectIdentifier && !shouldRemixOnSave);
42+
const showSaveButton = canSave && (isNewProject || shouldRemixOnSave);
43+
const showSaveStatus =
44+
canSave && canAutoSave && Boolean(lastSavedTime) && !isScratchSaveFailed;
45+
const projectLastSavedTime = getProjectLastSavedTime(project?.updated_at);
46+
47+
useEffect(() => {
48+
if (!canSave || !canAutoSave || lastSavedTime || !projectLastSavedTime) {
49+
return;
50+
}
51+
52+
dispatch(
53+
setScratchLastSavedTime({
54+
lastSavedTime: projectLastSavedTime,
55+
}),
56+
);
57+
}, [dispatch, canAutoSave, canSave, lastSavedTime, projectLastSavedTime]);
3158

3259
if (loading !== "success") {
3360
return null;
@@ -55,19 +82,20 @@ const ScratchProjectBar = ({ nameEditable = true }) => {
5582
type="tertiary"
5683
/>
5784
</div>
58-
{showScratchSaveButton && (
85+
{showSaveButton && (
5986
<div className="project-bar__btn-wrapper">
6087
<DesignSystemButton
6188
className="project-bar__btn btn--save btn--primary"
6289
onClick={() => saveScratchProject({ shouldRemixOnSave })}
63-
text={scratchSaveLabel}
90+
text={t("header.save")}
6491
textAlways
6592
icon={<SaveIcon />}
6693
type="primary"
6794
disabled={isScratchSaving}
6895
/>
6996
</div>
7097
)}
98+
{showSaveStatus && <SaveStatus />}
7199
</div>
72100
</div>
73101
);

0 commit comments

Comments
 (0)