Skip to content

Commit dc3af0a

Browse files
authored
issue 1185 - enable Scratch project download (#1360)
1 parent 34d5be2 commit dc3af0a

8 files changed

Lines changed: 280 additions & 10 deletions

File tree

src/components/DownloadButton/DownloadButton.jsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from "react";
22
import FileSaver from "file-saver";
3-
import { toSnakeCase } from "js-convert-case";
3+
import { toKebabCase, toSnakeCase } from "js-convert-case";
44
import JSZip from "jszip";
5+
import { postMessageToScratchIframe } from "../../utils/scratchIframe";
56
import JSZipUtils from "jszip-utils";
67
import { useTranslation } from "react-i18next";
78
import { useSelector } from "react-redux";
@@ -37,6 +38,14 @@ const DownloadButton = (props) => {
3738
window.plausible("Download");
3839
}
3940

41+
if (project.project_type === "code_editor_scratch") {
42+
postMessageToScratchIframe({
43+
type: "scratch-gui-download",
44+
filename: `${toKebabCase(project?.name) || "project"}.sb3`,
45+
});
46+
return;
47+
}
48+
4049
const zip = new JSZip();
4150

4251
if (project.instructions) {

src/components/DownloadButton/DownloadButton.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import DownloadButton from "./DownloadButton";
66
import FileSaver from "file-saver";
77
import JSZip from "jszip";
88
import JSZipUtils from "jszip-utils";
9+
import { postMessageToScratchIframe } from "../../utils/scratchIframe";
910

1011
jest.mock("file-saver");
1112
jest.mock("jszip");
1213
jest.mock("jszip-utils", () => ({
1314
getBinaryContent: jest.fn(),
1415
}));
16+
jest.mock("../../utils/scratchIframe");
1517

1618
describe("Downloading project with name set", () => {
1719
let downloadButton;
@@ -181,3 +183,83 @@ describe("Downloading project with no instructions set", () => {
181183
);
182184
});
183185
});
186+
187+
describe("When project is Scratch", () => {
188+
let downloadButton;
189+
190+
beforeEach(() => {
191+
postMessageToScratchIframe.mockClear();
192+
JSZip.mockClear();
193+
FileSaver.saveAs.mockClear();
194+
const middlewares = [];
195+
const mockStore = configureStore(middlewares);
196+
const initialState = {
197+
editor: {
198+
project: {
199+
name: "Cool Scratch",
200+
project_type: "code_editor_scratch",
201+
identifier: "cool-scratch",
202+
components: [],
203+
image_list: [],
204+
},
205+
},
206+
};
207+
const store = mockStore(initialState);
208+
render(
209+
<Provider store={store}>
210+
<DownloadButton buttonText="Download" Icon={() => {}} />
211+
</Provider>,
212+
);
213+
downloadButton = screen.queryByText("Download").parentElement;
214+
});
215+
216+
test("clicking download sends scratch-gui-download message with kebab filename", () => {
217+
fireEvent.click(downloadButton);
218+
expect(postMessageToScratchIframe).toHaveBeenCalledTimes(1);
219+
expect(postMessageToScratchIframe).toHaveBeenCalledWith({
220+
type: "scratch-gui-download",
221+
filename: "cool-scratch.sb3",
222+
});
223+
});
224+
225+
test("does not use JSZip or FileSaver", () => {
226+
fireEvent.click(downloadButton);
227+
expect(JSZip).not.toHaveBeenCalled();
228+
expect(FileSaver.saveAs).not.toHaveBeenCalled();
229+
});
230+
});
231+
232+
describe("When project is Scratch with no name", () => {
233+
let downloadButton;
234+
235+
beforeEach(() => {
236+
postMessageToScratchIframe.mockClear();
237+
const middlewares = [];
238+
const mockStore = configureStore(middlewares);
239+
const initialState = {
240+
editor: {
241+
project: {
242+
project_type: "code_editor_scratch",
243+
identifier: "cool-scratch",
244+
components: [],
245+
image_list: [],
246+
},
247+
},
248+
};
249+
const store = mockStore(initialState);
250+
render(
251+
<Provider store={store}>
252+
<DownloadButton buttonText="Download" Icon={() => {}} />
253+
</Provider>,
254+
);
255+
downloadButton = screen.queryByText("Download").parentElement;
256+
});
257+
258+
test("clicking download uses default filename project.sb3", () => {
259+
fireEvent.click(downloadButton);
260+
expect(postMessageToScratchIframe).toHaveBeenCalledWith({
261+
type: "scratch-gui-download",
262+
filename: "project.sb3",
263+
});
264+
});
265+
});

src/components/ProjectBar/ProjectBar.jsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SaveButton from "../SaveButton/SaveButton";
99

1010
import "../../assets/stylesheets/ProjectBar.scss";
1111
import { isOwner } from "../../utils/projectHelpers";
12+
import { postMessageToScratchIframe } from "../../utils/scratchIframe";
1213

1314
const ProjectBar = ({ nameEditable = true }) => {
1415
const { t } = useTranslation();
@@ -22,13 +23,7 @@ const ProjectBar = ({ nameEditable = true }) => {
2223
const readOnly = useSelector((state) => state.editor.readOnly);
2324

2425
const saveScratchProject = () => {
25-
const webComponent = document.querySelector("editor-wc");
26-
webComponent.shadowRoot
27-
.querySelector("iframe[title='Scratch']")
28-
.contentWindow.postMessage(
29-
{ type: "scratch-gui-save" },
30-
process.env.ASSETS_URL,
31-
);
26+
postMessageToScratchIframe({ type: "scratch-gui-save" });
3227
};
3328

3429
return (

src/components/ProjectBar/ProjectBar.test.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import React from "react";
2-
import { render, screen } from "@testing-library/react";
2+
import { fireEvent, render, screen } from "@testing-library/react";
33
import { Provider } from "react-redux";
44
import configureStore from "redux-mock-store";
55
import { MemoryRouter } from "react-router-dom";
66
import ProjectBar from "./ProjectBar";
7+
import { postMessageToScratchIframe } from "../../utils/scratchIframe";
78

89
jest.mock("axios");
10+
jest.mock("../../utils/scratchIframe");
911

1012
jest.mock("react-router-dom", () => ({
1113
...jest.requireActual("react-router-dom"),
@@ -189,3 +191,31 @@ describe("When read only", () => {
189191
expect(screen.queryByText(/saveStatus.saved/)).not.toBeInTheDocument();
190192
});
191193
});
194+
195+
describe("When project is Scratch", () => {
196+
const scratchProject = {
197+
...project,
198+
project_type: "code_editor_scratch",
199+
};
200+
201+
beforeEach(() => {
202+
postMessageToScratchIframe.mockClear();
203+
renderProjectBar({
204+
editor: {
205+
project: scratchProject,
206+
loading: "success",
207+
},
208+
auth: {
209+
user: user,
210+
},
211+
});
212+
});
213+
214+
test("clicking Save sends scratch-gui-save message", () => {
215+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
216+
expect(postMessageToScratchIframe).toHaveBeenCalledTimes(1);
217+
expect(postMessageToScratchIframe).toHaveBeenCalledWith({
218+
type: "scratch-gui-save",
219+
});
220+
});
221+
});

src/components/ScratchEditor/ScratchIntegrationHOC.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import PropTypes from "prop-types";
33
import { connect } from "react-redux";
4+
import { saveAs } from "file-saver";
45
import {
56
remixProject,
67
manualUpdateProject,
@@ -63,7 +64,7 @@ const ScratchIntegrationHOC = function (WrappedComponent) {
6364
handleDownload(event) {
6465
const filename = event.data.filename;
6566
this.props.saveProjectSb3().then((content) => {
66-
console.log("Downloading project as", content, filename);
67+
saveAs(content, filename);
6768
});
6869
}
6970
handleUpload(event) {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const React = require("react");
2+
const { render, waitFor } = require("@testing-library/react");
3+
const { Provider } = require("react-redux");
4+
const configureStore = require("redux-mock-store").default;
5+
const ScratchIntegrationHOC = require("./ScratchIntegrationHOC").default;
6+
7+
jest.mock("file-saver", () => ({ saveAs: jest.fn() }));
8+
jest.mock("@scratch/scratch-gui", () => ({
9+
remixProject: () => ({ type: "remix" }),
10+
manualUpdateProject: () => ({ type: "manualUpdate" }),
11+
setStageSize: () => ({ type: "setStageSize" }),
12+
}));
13+
14+
describe("ScratchIntegrationHOC", () => {
15+
const mockSaveProjectSb3 = jest.fn();
16+
const mockLoadProject = jest.fn();
17+
const allowedOrigin = "https://editor.example.com";
18+
let store;
19+
let Wrapped;
20+
let saveAs;
21+
22+
beforeEach(() => {
23+
const fileSaver = require("file-saver");
24+
saveAs = fileSaver.saveAs || fileSaver;
25+
if (typeof saveAs.mockClear === "function") {
26+
saveAs.mockClear();
27+
}
28+
mockSaveProjectSb3.mockClear();
29+
process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS = allowedOrigin;
30+
const mockStore = configureStore([]);
31+
store = mockStore({
32+
scratchGui: {
33+
vm: {
34+
saveProjectSb3: mockSaveProjectSb3,
35+
loadProject: mockLoadProject,
36+
},
37+
},
38+
});
39+
const Dummy = () =>
40+
React.createElement("div", { "data-testid": "wrapped" });
41+
Wrapped = ScratchIntegrationHOC(Dummy);
42+
});
43+
44+
afterEach(() => {
45+
delete process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS;
46+
});
47+
48+
describe("scratch-gui-download message", () => {
49+
it("calls saveProjectSb3 and saveAs with blob and filename", async () => {
50+
const mockBlob = new Blob(["x"], {
51+
type: "application/octet-stream",
52+
});
53+
mockSaveProjectSb3.mockResolvedValue(mockBlob);
54+
55+
render(
56+
React.createElement(Provider, { store }, React.createElement(Wrapped)),
57+
);
58+
59+
window.dispatchEvent(
60+
new MessageEvent("message", {
61+
origin: allowedOrigin,
62+
data: {
63+
type: "scratch-gui-download",
64+
filename: "my-project.sb3",
65+
},
66+
}),
67+
);
68+
69+
await waitFor(() => {
70+
expect(saveAs).toHaveBeenCalledWith(mockBlob, "my-project.sb3");
71+
});
72+
expect(mockSaveProjectSb3).toHaveBeenCalledTimes(1);
73+
});
74+
});
75+
});

src/utils/scratchIframe.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const getScratchIframeContentWindow = () => {
2+
const webComponent = document.querySelector("editor-wc");
3+
return webComponent.shadowRoot.querySelector("iframe[title='Scratch']")
4+
.contentWindow;
5+
};
6+
7+
export const postMessageToScratchIframe = (message) => {
8+
getScratchIframeContentWindow().postMessage(message, process.env.ASSETS_URL);
9+
};

src/utils/scratchIframe.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
getScratchIframeContentWindow,
3+
postMessageToScratchIframe,
4+
} from "./scratchIframe";
5+
6+
describe("scratchIframe", () => {
7+
let mockPostMessage;
8+
let mockContentWindow;
9+
let mockShadowQuerySelector;
10+
let originalQuerySelector;
11+
12+
beforeEach(() => {
13+
mockPostMessage = jest.fn();
14+
mockContentWindow = { postMessage: mockPostMessage };
15+
mockShadowQuerySelector = jest.fn(() => ({
16+
contentWindow: mockContentWindow,
17+
}));
18+
originalQuerySelector = document.querySelector;
19+
document.querySelector = jest.fn(() => ({
20+
shadowRoot: {
21+
querySelector: mockShadowQuerySelector,
22+
},
23+
}));
24+
});
25+
26+
afterEach(() => {
27+
document.querySelector = originalQuerySelector;
28+
});
29+
30+
describe("getScratchIframeContentWindow", () => {
31+
it("returns the Scratch iframe contentWindow", () => {
32+
const result = getScratchIframeContentWindow();
33+
expect(document.querySelector).toHaveBeenCalledWith("editor-wc");
34+
expect(result).toBe(mockContentWindow);
35+
});
36+
37+
it("queries iframe by Scratch title", () => {
38+
getScratchIframeContentWindow();
39+
expect(mockShadowQuerySelector).toHaveBeenCalledWith(
40+
"iframe[title='Scratch']",
41+
);
42+
});
43+
});
44+
45+
describe("postMessageToScratchIframe", () => {
46+
const originalEnv = process.env;
47+
48+
beforeEach(() => {
49+
process.env = {
50+
...originalEnv,
51+
ASSETS_URL: "https://assets.example.com",
52+
};
53+
});
54+
55+
afterEach(() => {
56+
process.env = originalEnv;
57+
});
58+
59+
it("calls postMessage on the Scratch iframe with message and ASSETS_URL", () => {
60+
const message = { type: "scratch-gui-download", filename: "cool.sb3" };
61+
postMessageToScratchIframe(message);
62+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
63+
expect(mockPostMessage).toHaveBeenCalledWith(
64+
message,
65+
"https://assets.example.com",
66+
);
67+
});
68+
});
69+
});

0 commit comments

Comments
 (0)