Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress/e2e/spec-scratch.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const getIframeBody = () => {
describe("Scratch", () => {
beforeEach(() => {
cy.visit(origin);
cy.findByText("blank-scratch").click();
cy.findByText("cool-scratch").click();
});

it("loads Scratch in an iframe", () => {
Expand Down
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<h1>You may be looking for...</h1>
<ul>
<li>Web Component test page <a href="http://localhost:3011/web-component.html">http://localhost:3011/web-component.html</a></li>
<li>Web Component test page <a href="/web-component.html">/web-component.html</a></li>
</ul>
18 changes: 17 additions & 1 deletion src/components/Editor/Project/ScratchContainer.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import React from "react";
import { useSelector } from "react-redux";

export default function ScratchContainer() {
const projectIdentifier = useSelector(
(state) => state.editor.project.identifier,
);
const scratchApiEndpoint = useSelector(
(state) => state.editor.scratchApiEndpoint,
);

const queryParams = new URLSearchParams();
queryParams.set("project_id", projectIdentifier);
queryParams.set("api_url", scratchApiEndpoint);

const iframeSrcUrl = `${
process.env.ASSETS_URL
}/scratch.html?${queryParams.toString()}`;

return (
<iframe
src={`${process.env.ASSETS_URL}/scratch.html`}
src={iframeSrcUrl}
title={"Scratch"}
style={{
width: "100%",
Expand Down
44 changes: 44 additions & 0 deletions src/components/Editor/Project/ScratchContainer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import configureStore from "redux-mock-store";
import { render, screen } from "@testing-library/react";
import React from "react";
import { Provider } from "react-redux";
import ScratchContainer from "./ScratchContainer";

describe("ScratchContainer", () => {
let originalAssetsUrl;

beforeEach(() => {
originalAssetsUrl = process.env.ASSETS_URL;
process.env.ASSETS_URL = "https://example.com";
});

afterEach(() => {
process.env.ASSETS_URL = originalAssetsUrl;
});

test("renders iframe with src built from project_id and api_url", () => {
const mockStore = configureStore([]);
const store = mockStore({
editor: {
project: {
identifier: "project-123",
},
scratchApiEndpoint: "https://api.example.com/v1",
},
});

render(
<Provider store={store}>
<ScratchContainer />
</Provider>,
);

const iframe = screen.getByTitle("Scratch");
expect(iframe).toBeInTheDocument();

const url = new URL(iframe.getAttribute("src"));
expect(url.pathname).toBe("/scratch.html");
expect(url.searchParams.get("project_id")).toBe("project-123");
expect(url.searchParams.get("api_url")).toBe("https://api.example.com/v1");
});
});
18 changes: 18 additions & 0 deletions src/components/ProjectBar/ProjectBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ const ProjectBar = ({ nameEditable = true }) => {
const projectOwner = isOwner(user, project);
const readOnly = useSelector((state) => state.editor.readOnly);

const saveScratchProject = () => {
const webComponent = document.querySelector("editor-wc");
webComponent.shadowRoot
.querySelector("iframe[title='Scratch']")
.contentWindow.postMessage(
{ type: "scratch-gui-save" },
process.env.ASSETS_URL,
);
};

return (
loading === "success" && (
<div className="project-bar">
Expand All @@ -43,6 +53,14 @@ const ProjectBar = ({ nameEditable = true }) => {
<SaveButton className="project-bar__btn btn--save" />
</div>
)}
{project.project_type === "code_editor_scratch" && (
<button
className="project-bar__btn btn--save"
onClick={saveScratchProject}
>
Save
</button>
)}
{lastSavedTime && user && !readOnly && (
<SaveStatus saving={saving} lastSavedTime={lastSavedTime} />
)}
Expand Down
6 changes: 6 additions & 0 deletions src/containers/WebComponentLoader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
setSenseHatAlwaysEnabled,
setLoadRemixDisabled,
setReactAppApiEndpoint,
setScratchApiEndpoint,
setReadOnly,
} from "../redux/EditorSlice";
import WebComponentProject from "../components/WebComponentProject/WebComponentProject";
Expand Down Expand Up @@ -50,6 +51,7 @@ const WebComponentLoader = (props) => {
sidebarPlugins = [],
projectNameEditable = false,
reactAppApiEndpoint = process.env.REACT_APP_API_ENDPOINT,
scratchApiEndpoint = process.env.REACT_APP_API_ENDPOINT,
readOnly = false,
senseHatAlwaysEnabled = false,
showSavePrompt = false,
Expand Down Expand Up @@ -164,6 +166,10 @@ const WebComponentLoader = (props) => {
dispatch(setReactAppApiEndpoint(reactAppApiEndpoint));
}, [reactAppApiEndpoint, dispatch]);

useEffect(() => {
dispatch(setScratchApiEndpoint(scratchApiEndpoint));
}, [scratchApiEndpoint, dispatch]);

useEffect(() => {
dispatch(setSenseHatAlwaysEnabled(senseHatAlwaysEnabled));
}, [senseHatAlwaysEnabled, dispatch]);
Expand Down
40 changes: 40 additions & 0 deletions src/containers/WebComponentLoader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
setReadOnly,
setSenseHatAlwaysEnabled,
setReactAppApiEndpoint,
setScratchApiEndpoint,
} from "../redux/EditorSlice";
import { setInstructions } from "../redux/InstructionsSlice";
import { setUser } from "../redux/WebComponentAuthSlice";
Expand Down Expand Up @@ -108,6 +109,45 @@ describe("When initially rendered", () => {
expect(mockedChangeLanguage).toHaveBeenCalledWith("es-LA");
});

describe("scratch API endpoint", () => {
describe("when scratch API endpoint isn't set", () => {
beforeEach(() => {
render(
<Provider store={store}>
<WebComponentLoader />
</Provider>,
);
});

test("it defaults to env", () => {
expect(store.getActions()).toEqual(
expect.arrayContaining([
setScratchApiEndpoint("http://localhost:3009"),
]),
);
});
});

describe("when scratch API endpoint is set", () => {
beforeEach(() => {
render(
<Provider store={store}>
<WebComponentLoader
scratchApiEndpoint="http://local.dev"
theme="light"
/>
</Provider>,
);
});

test("it uses the specified prop", () => {
expect(store.getActions()).toEqual(
expect.arrayContaining([setScratchApiEndpoint("http://local.dev")]),
);
});
});
});

describe("react app API endpoint", () => {
describe("when react app API endpoint isn't set", () => {
beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"identifier": "blank-scratch",
"identifier": "cool-scratch.json",
"project_type": "code_editor_scratch",
"locale": "en",
"name": "Blank Scratch Project",
"name": "Sample Scratch Project",
"user_id": null,
"instructions": {
"content": "instructions go here",
Expand Down
4 changes: 4 additions & 0 deletions src/redux/EditorSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ export const EditorSlice = createSlice({
setReactAppApiEndpoint: (state, action) => {
state.reactAppApiEndpoint = action.payload;
},
setScratchApiEndpoint: (state, action) => {
state.scratchApiEndpoint = action.payload;
},
triggerDraw: (state) => {
state.drawTriggered = true;
},
Expand Down Expand Up @@ -442,6 +445,7 @@ export const {
setSenseHatEnabled,
setLoadRemixDisabled,
setReactAppApiEndpoint,
setScratchApiEndpoint,
stopCodeRun,
stopDraw,
triggerCodeRun,
Expand Down
49 changes: 28 additions & 21 deletions src/scratch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ if (process.env.NODE_ENV === "production" && typeof window === "object") {
window.onbeforeunload = () => true;
}

const defaultProjectId = "cool-id.json";
const projectId = appTarget.dataset.projectId || defaultProjectId;
const searchParams = new URLSearchParams(window.location.search);
const projectId = searchParams.get("project_id");
const apiUrl = searchParams.get("api_url");

const defaultLocale = "en";
const locale = appTarget.dataset.locale || defaultLocale;
Expand Down Expand Up @@ -48,22 +49,28 @@ const handleSavingSucceeded = () => {
window.top.postMessage({ type: "scratch-gui-saving-succeeded" }, "*");
};

const root = createRoot(appTarget);
root.render(
<>
<style>{ScratchStyles}</style>
<WrappedGui
projectId={projectId}
locale={locale}
menuBarHidden={true}
projectHost={`${process.env.ASSETS_URL}/api/projects`}
assetHost={`${process.env.ASSETS_URL}/api/assets`}
basePath={`${process.env.ASSETS_URL}/scratch-gui/`}
onUpdateProjectId={handleUpdateProjectId}
onShowCreatingRemixAlert={handleRemixingStarted}
onShowRemixSuccessAlert={handleRemixingSucceeded}
onShowSavingAlert={handleSavingStarted}
onShowSaveSuccessAlert={handleSavingSucceeded}
/>
</>,
);
if (!projectId) {
console.error("project_id is required but not set");
} else if (!apiUrl) {
console.error("api_url is required but not set");
} else {
const root = createRoot(appTarget);
root.render(
<>
<style>{ScratchStyles}</style>
<WrappedGui
projectId={projectId}
locale={locale}
menuBarHidden={true}
projectHost={`${apiUrl}/api/scratch/projects`}
assetHost={`${apiUrl}/api/scratch/assets`}
basePath={`${process.env.ASSETS_URL}/scratch-gui/`}
onUpdateProjectId={handleUpdateProjectId}
onShowCreatingRemixAlert={handleRemixingStarted}
onShowRemixSuccessAlert={handleRemixingSucceeded}
onShowSavingAlert={handleSavingStarted}
onShowSaveSuccessAlert={handleSavingSucceeded}
/>
</>,
);
}
42 changes: 19 additions & 23 deletions src/utils/apiCallHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,24 @@ import omit from "lodash/omit";
const ApiCallHandler = ({ reactAppApiEndpoint }) => {
const host = reactAppApiEndpoint;

const get = async (url, headers) => {
return await axios.get(url, headers);
const get = async (url, config) => {
return await axios.get(url, config);
};

const post = async (url, body, headers) => {
return await axios.post(url, body, headers);
const post = async (url, body, config) => {
return await axios.post(url, body, config);
};

const put = async (url, body, headers) => {
return await axios.put(url, body, headers);
const put = async (url, body, config) => {
return await axios.put(url, body, config);
};

const headers = (accessToken) => {
let headersHash;
if (accessToken) {
headersHash = { Accept: "application/json", Authorization: accessToken };
return { Accept: "application/json", Authorization: accessToken };
} else {
headersHash = { Accept: "application/json" };
return { Accept: "application/json" };
}
return { headers: headersHash };
};

const createOrUpdateProject = async (projectWithUserId, accessToken) => {
Expand All @@ -32,52 +30,50 @@ const ApiCallHandler = ({ reactAppApiEndpoint }) => {
return await post(
`${host}/api/projects`,
{ project },
headers(accessToken),
{ headers: headers(accessToken) },
);
} else {
return await put(
`${host}/api/projects/${project.identifier}`,
{ project },
headers(accessToken),
{ headers: headers(accessToken) },
);
}
};

const deleteProject = async (identifier, accessToken) => {
return await axios.delete(
`${host}/api/projects/${identifier}`,
headers(accessToken),
);
return await axios.delete(`${host}/api/projects/${identifier}`, {
headers: headers(accessToken),
});
};

const loadRemix = async (projectIdentifier, accessToken) => {
return await get(
`${host}/api/projects/${projectIdentifier}/remix`,
headers(accessToken),
);
return await get(`${host}/api/projects/${projectIdentifier}/remix`, {
headers: headers(accessToken),
});
};

const createRemix = async (project, accessToken) => {
return await post(
`${host}/api/projects/${project.identifier}/remix`,
{ project },
headers(accessToken),
{ headers: headers(accessToken) },
);
};

const readProject = async (projectIdentifier, locale, accessToken) => {
const queryString = locale ? `?locale=${locale}` : "";
return await get(
`${host}/api/projects/${projectIdentifier}${queryString}`,
headers(accessToken),
{ headers: headers(accessToken), withCredentials: true },
);
};

const loadAssets = async (assetsIdentifier, locale, accessToken) => {
const queryString = locale ? `?locale=${locale}` : "";
return await get(
`${host}/api/projects/${assetsIdentifier}/images${queryString}`,
headers(accessToken),
{ headers: headers(accessToken) },
);
};

Expand Down
Loading