Skip to content
Draft
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
5 changes: 3 additions & 2 deletions public/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@
},
"saveStatus": {
"saving": "Saving",
"saved": "Saved"
"saved": "Saved",
"failed": "Offline - changes not saved"
},
"runners": {
"HtmlOutput": "HTML output preview"
Expand Down Expand Up @@ -312,4 +313,4 @@
"common": {
"or": "or"
}
}
}
3 changes: 2 additions & 1 deletion public/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@
},
"saveStatus": {
"saving": "Saving",
"saved": "Saved"
"saved": "Saved",
"failed": "Offline - changes not saved"
},
"runners": {
"HtmlOutput": "HTML Output Preview"
Expand Down
24 changes: 21 additions & 3 deletions src/components/ProjectBar/ScratchProjectBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ProjectName from "../ProjectName/ProjectName";
import DownloadButton from "../DownloadButton/DownloadButton";
import UploadButton from "../UploadButton/UploadButton";
import DesignSystemButton from "../DesignSystemButton/DesignSystemButton";
import SaveStatus from "../SaveStatus/SaveStatus";

import "../../assets/stylesheets/ProjectBar.scss";
import { useScratchSave } from "../../hooks/useScratchSave";
Expand All @@ -20,14 +21,24 @@ const ScratchProjectBar = ({ nameEditable = true }) => {
const readOnly = useSelector((state) => state.editor.readOnly);
const showScratchSaveButton = Boolean(user && !readOnly);
const {
isScratchSaveFailed,
isScratchSaving,
saveScratchProject,
scratchSaveLabelKey,
scratchLastSavedTime,
shouldRemixOnSave,
} = useScratchSave({
enabled: showScratchSaveButton,
});
const scratchSaveLabel = t(scratchSaveLabelKey);
const hasScratchSaveStatus = Boolean(
isScratchSaving || isScratchSaveFailed || scratchLastSavedTime,
);
const showScratchSaveStatus = showScratchSaveButton && hasScratchSaveStatus;
let scratchSaveStatus = "success";
if (isScratchSaveFailed) {
scratchSaveStatus = "failed";
} else if (isScratchSaving) {
scratchSaveStatus = "pending";
}

if (loading !== "success") {
return null;
Expand Down Expand Up @@ -60,14 +71,21 @@ const ScratchProjectBar = ({ nameEditable = true }) => {
<DesignSystemButton
className="project-bar__btn btn--save btn--primary"
onClick={() => saveScratchProject({ shouldRemixOnSave })}
text={scratchSaveLabel}
text={t("header.save")}
textAlways
icon={<SaveIcon />}
type="primary"
disabled={isScratchSaving}
/>
</div>
)}
{showScratchSaveStatus && (
<SaveStatus
saving={scratchSaveStatus}
lastSavedTime={scratchLastSavedTime}
loading={loading}
/>
)}
</div>
</div>
);
Expand Down
139 changes: 83 additions & 56 deletions src/components/ProjectBar/ScratchProjectBar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jest.mock("react-router-dom", () => ({
useNavigate: () => jest.fn(),
}));

jest.useFakeTimers();

const scratchProject = {
name: "Hello world",
identifier: "hello-world-project",
Expand Down Expand Up @@ -59,6 +61,22 @@ const renderScratchProjectBar = (state) => {
);
};

const renderSignedInScratchProjectBar = ({
project = scratchProject,
editor = {},
auth = {},
} = {}) =>
renderScratchProjectBar({
editor: {
project,
...editor,
},
auth: {
user,
...auth,
},
});

const getScratchOrigin = () => process.env.ASSETS_URL || window.location.origin;

const dispatchScratchMessage = (type, origin = getScratchOrigin()) => {
Expand All @@ -76,28 +94,26 @@ beforeEach(() => {
jest.clearAllMocks();
});

describe("When project is Scratch", () => {
beforeEach(() => {
postMessageToScratchIframe.mockClear();
renderScratchProjectBar({
editor: {
project: scratchProject,
},
auth: {
user,
},
});
});
afterEach(() => {
jest.clearAllTimers();
});

describe("When project is Scratch", () => {
test("Upload button shown", () => {
renderSignedInScratchProjectBar();

expect(screen.queryByText("header.upload")).toBeInTheDocument();
});

test("Download button shown", () => {
renderSignedInScratchProjectBar();

expect(screen.queryByText("header.download")).toBeInTheDocument();
});

test("clicking Save sends scratch-gui-save message", () => {
renderSignedInScratchProjectBar();

fireEvent.click(screen.getByRole("button", { name: "header.save" }));

expect(postMessageToScratchIframe).toHaveBeenCalledTimes(1);
Expand All @@ -107,79 +123,90 @@ describe("When project is Scratch", () => {
});

test("clicking Save remixes a non-owner Scratch project on the first save", () => {
renderScratchProjectBar({
editor: {
project: {
...scratchProject,
user_id: "teacher-id",
},
},
auth: {
user,
renderSignedInScratchProjectBar({
project: {
...scratchProject,
user_id: "teacher-id",
},
});

fireEvent.click(screen.getAllByRole("button", { name: "header.save" })[1]);
fireEvent.click(screen.getByRole("button", { name: "header.save" }));

expect(postMessageToScratchIframe).toHaveBeenCalledWith({
type: "scratch-gui-remix",
});
});

test("auto-saves after a Scratch project change", () => {
renderSignedInScratchProjectBar();

dispatchScratchMessage("scratch-gui-project-changed");

act(() => {
jest.advanceTimersByTime(2000);
});

expect(postMessageToScratchIframe).toHaveBeenCalledWith({
type: "scratch-gui-save",
});
});
});

describe("Additional Scratch manual save states", () => {
test("shows the saving state from the scratch save hook", () => {
renderScratchProjectBar({
editor: {
project: scratchProject,
},
auth: {
user,
},
});
renderSignedInScratchProjectBar();

dispatchScratchMessage("scratch-gui-saving-started");

expect(
screen.getByRole("button", { name: "saveStatus.saving" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "header.save" })).toBeDisabled();
expect(screen.getByText(/saveStatus.saving/)).toBeInTheDocument();
});

test("shows the saved state from the scratch save hook", () => {
renderScratchProjectBar({
editor: {
project: scratchProject,
},
auth: {
user,
},
});
renderSignedInScratchProjectBar();

dispatchScratchMessage("scratch-gui-saving-succeeded");

expect(
screen.getByRole("button", { name: "saveStatus.saved" }),
).toBeInTheDocument();
expect(screen.getByText("saveStatus.saved now")).toBeInTheDocument();
});

test("shows the failed state from the scratch save hook", () => {
renderSignedInScratchProjectBar();

dispatchScratchMessage("scratch-gui-saving-failed");

expect(screen.getByText("saveStatus.failed")).toBeInTheDocument();
});

test("shows the saving state during a Scratch remix", () => {
renderScratchProjectBar({
editor: {
project: {
...scratchProject,
user_id: "teacher-id",
},
},
auth: {
user,
renderSignedInScratchProjectBar({
project: {
...scratchProject,
user_id: "teacher-id",
},
});

dispatchScratchMessage("scratch-gui-remixing-started");

expect(
screen.getByRole("button", { name: "saveStatus.saving" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "header.save" })).toBeDisabled();
expect(screen.getByText(/saveStatus.saving/)).toBeInTheDocument();
});

test("does not auto-save a non-owner Scratch project before the first remix save", () => {
renderSignedInScratchProjectBar({
project: {
...scratchProject,
user_id: "teacher-id",
},
});

dispatchScratchMessage("scratch-gui-project-changed");

act(() => {
jest.advanceTimersByTime(2000);
});

expect(postMessageToScratchIframe).not.toHaveBeenCalled();
});

test("does not show save for logged-out Scratch users", () => {
Expand Down
Loading
Loading