diff --git a/.env.webcomponent.example b/.env.webcomponent.example
index d1f1f62e1..84460ebf7 100644
--- a/.env.webcomponent.example
+++ b/.env.webcomponent.example
@@ -1,4 +1,5 @@
-# NB This is the URL of react-ui, rather than the web component
+REACT_APP_AUTHENTICATION_URL='http://localhost:9001'
REACT_APP_SENTRY_DSN=''
REACT_APP_SENTRY_ENV='local'
+# NB This is the URL of react-ui, rather than the web component
PUBLIC_URL=http://localhost:3000
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index d9295dd8b..c972ff7d4 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -55,7 +55,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run tests
- run: yarn run test --coverage --maxWorkers=4 --reporters=default --reporters=jest-junit --reporters=jest-github-actions-reporter
+ run: yarn run test --coverage --maxWorkers=4 --workerThreads=true --reporters=default --reporters=jest-junit --reporters=jest-github-actions-reporter
env:
JEST_JUNIT_OUTPUT_DIR: ./coverage/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d892015c2..f7ced548f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Web component tests (#709, #710)
- `instructions` attribute for the web component (#712)
- Instructions slice to store data passed from the Projects site (#712)
+- Adding auth to the web component (#728)
+- Allow web component to load, save and remix projects (#728)
### Changed
diff --git a/src/app/WebComponentStore.js b/src/app/WebComponentStore.js
new file mode 100644
index 000000000..bf2bde912
--- /dev/null
+++ b/src/app/WebComponentStore.js
@@ -0,0 +1,24 @@
+import { configureStore } from "@reduxjs/toolkit";
+import EditorReducer from "../redux/EditorSlice";
+import InstructionsReducer from "../redux/InstructionsSlice";
+import WebComponentAuthReducer from "../redux/WebComponentAuthSlice";
+
+const store = configureStore({
+ reducer: {
+ editor: EditorReducer,
+ instructions: InstructionsReducer,
+ auth: WebComponentAuthReducer,
+ },
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActions: [
+ "redux-oidc/USER_FOUND",
+ "redux-odic/SILENT_RENEW_ERROR",
+ ],
+ ignoredPaths: ["auth.user"],
+ },
+ }),
+});
+
+export default store;
diff --git a/src/components/EmbeddedViewer/EmbeddedViewer.jsx b/src/components/EmbeddedViewer/EmbeddedViewer.jsx
index 0a37bfa0f..fb20fdf88 100644
--- a/src/components/EmbeddedViewer/EmbeddedViewer.jsx
+++ b/src/components/EmbeddedViewer/EmbeddedViewer.jsx
@@ -6,7 +6,7 @@ import { useSelector } from "react-redux";
import { useProject } from "../../hooks/useProject";
import { useEmbeddedMode } from "../../hooks/useEmbeddedMode";
import Output from "../Editor/Output/Output";
-import { useParams } from "react-router-dom";
+import { useParams, useSearchParams } from "react-router-dom";
import NotFoundModalEmbedded from "../Modals/NotFoundModalEmbedded";
import AccessDeniedNoAuthModalEmbedded from "../Modals/AccessDeniedNoAuthModalEmbedded";
@@ -20,11 +20,14 @@ const EmbeddedViewer = () => {
);
const { identifier } = useParams();
const user = useSelector((state) => state.auth.user) || {};
+ const [searchParams] = useSearchParams();
+ const isBrowserPreview = searchParams.get("browserPreview") === "true";
useProject({
projectIdentifier: identifier,
accessToken: user.access_token,
isEmbedded: true,
+ isBrowserPreview,
});
useEmbeddedMode(true);
diff --git a/src/components/EmbeddedViewer/EmbeddedViewer.test.js b/src/components/EmbeddedViewer/EmbeddedViewer.test.js
index 6183c2491..90ce8c168 100644
--- a/src/components/EmbeddedViewer/EmbeddedViewer.test.js
+++ b/src/components/EmbeddedViewer/EmbeddedViewer.test.js
@@ -6,11 +6,18 @@ import configureStore from "redux-mock-store";
import { render, screen } from "@testing-library/react";
import { useProject } from "../../hooks/useProject";
+let mockBrowserPreview = false;
+
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useParams: () => ({
identifier: "my-amazing-project",
}),
+ useSearchParams: () => [
+ {
+ get: (key) => (key === "browserPreview" ? mockBrowserPreview : null),
+ },
+ ],
}));
jest.mock("../../hooks/useProject", () => ({
@@ -86,6 +93,33 @@ test("Loads project with correct params", () => {
projectIdentifier: "my-amazing-project",
accessToken: "my_token",
isEmbedded: true,
+ isBrowserPreview: false,
+ });
+});
+
+test("Loads project with correct params if browser preview", () => {
+ initialState = {
+ ...initialState,
+ editor: {
+ ...initialState.editor,
+ loading: "success",
+ },
+ };
+
+ const mockStore = configureStore([]);
+ store = mockStore(initialState);
+ mockBrowserPreview = "true";
+
+ render(
+
+
+ ,
+ );
+ expect(useProject).toHaveBeenCalledWith({
+ projectIdentifier: "my-amazing-project",
+ accessToken: "my_token",
+ isEmbedded: true,
+ isBrowserPreview: true,
});
});
diff --git a/src/components/SaveButton/SaveButton.jsx b/src/components/SaveButton/SaveButton.jsx
index 83c3a63e0..e5e01b993 100644
--- a/src/components/SaveButton/SaveButton.jsx
+++ b/src/components/SaveButton/SaveButton.jsx
@@ -1,36 +1,19 @@
+import React from "react";
import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import DesignSystemButton from "../DesignSystemButton/DesignSystemButton";
import SaveIcon from "../../assets/icons/save.svg";
-import { syncProject, showLoginToSaveModal } from "../../redux/EditorSlice";
-import { isOwner } from "../../utils/projectHelpers";
+import { triggerSave } from "../../redux/EditorSlice";
const SaveButton = ({ className, type = "secondary" }) => {
const dispatch = useDispatch();
const { t } = useTranslation();
- const user = useSelector((state) => state.auth.user);
- const project = useSelector((state) => state.editor.project);
const loading = useSelector((state) => state.editor.loading);
const onClickSave = async () => {
window.plausible("Save button");
-
- if (isOwner(user, project)) {
- dispatch(
- syncProject("save")({
- project,
- accessToken: user.access_token,
- autosave: false,
- }),
- );
- } else if (user && project.identifier) {
- dispatch(
- syncProject("remix")({ project, accessToken: user.access_token }),
- );
- } else {
- dispatch(showLoginToSaveModal());
- }
+ dispatch(triggerSave());
};
return (
diff --git a/src/components/SaveButton/SaveButton.test.js b/src/components/SaveButton/SaveButton.test.js
index 9dfd6d37e..51d14bee4 100644
--- a/src/components/SaveButton/SaveButton.test.js
+++ b/src/components/SaveButton/SaveButton.test.js
@@ -1,128 +1,11 @@
import React from "react";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
-import { syncProject, showLoginToSaveModal } from "../../redux/EditorSlice";
-import { MemoryRouter } from "react-router-dom";
+import { triggerSave } from "../../redux/EditorSlice";
import SaveButton from "./SaveButton";
-jest.mock("axios");
-
-jest.mock("react-router-dom", () => ({
- ...jest.requireActual("react-router-dom"),
- useNavigate: () => jest.fn(),
-}));
-
-jest.mock("../../redux/EditorSlice", () => ({
- ...jest.requireActual("../../redux/EditorSlice"),
- syncProject: jest.fn((_) => jest.fn()),
-}));
-
-const project = {
- name: "Hello world",
- identifier: "hello-world-project",
- components: [],
- image_list: [],
- user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf",
-};
-const user = {
- access_token: "39a09671-be55-4847-baf5-8919a0c24a25",
- profile: {
- user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf",
- },
-};
-
-describe("When logged in and user owns project", () => {
- let store;
- let saveButton;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: project,
- loading: "success",
- },
- auth: {
- user: user,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- saveButton = screen.queryByText("header.save");
- });
-
- test("Clicking save dispatches saveProject with correct parameters", async () => {
- const saveAction = { type: "SAVE_PROJECT" };
- const saveProject = jest.fn(() => saveAction);
- syncProject.mockImplementationOnce(jest.fn((_) => saveProject));
- fireEvent.click(saveButton);
- await waitFor(() =>
- expect(saveProject).toHaveBeenCalledWith({
- project,
- accessToken: user.access_token,
- autosave: false,
- }),
- );
- expect(store.getActions()[0]).toEqual(saveAction);
- });
-});
-
-describe("When logged in and no project identifier", () => {
- let store;
- const project_without_id = { ...project, identifier: null };
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: project_without_id,
- loading: "success",
- },
- auth: {
- user: user,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- });
-
- test("Clicking save dispatches saveProject with correct parameters", async () => {
- const saveAction = { type: "SAVE_PROJECT" };
- const saveProject = jest.fn(() => saveAction);
- syncProject.mockImplementationOnce(jest.fn((_) => saveProject));
- const saveButton = screen.getByText("header.save");
- fireEvent.click(saveButton);
- await waitFor(() =>
- expect(saveProject).toHaveBeenCalledWith({
- project: project_without_id,
- accessToken: user.access_token,
- autosave: false,
- }),
- );
- expect(store.getActions()[0]).toEqual(saveAction);
- });
-});
-
-describe("When logged in and user does not own project", () => {
- const another_project = {
- ...project,
- user_id: "5254370e-26d2-4c8a-9526-8dbafea43aa9",
- };
+describe("When project is loaded", () => {
let store;
beforeEach(() => {
@@ -130,95 +13,42 @@ describe("When logged in and user does not own project", () => {
const mockStore = configureStore(middlewares);
const initialState = {
editor: {
- project: another_project,
loading: "success",
},
- auth: {
- user: user,
- },
};
store = mockStore(initialState);
render(
-
-
-
+
,
);
});
- test("Clicking save dispatches remixProject with correct parameters", async () => {
- const remixAction = { type: "REMIX_PROJECT" };
- const remixProject = jest.fn(() => remixAction);
- syncProject.mockImplementationOnce(jest.fn((_) => remixProject));
- const saveButton = screen.getByText("header.save");
- fireEvent.click(saveButton);
- await waitFor(() =>
- expect(remixProject).toHaveBeenCalledWith({
- project: another_project,
- accessToken: user.access_token,
- }),
- );
- expect(store.getActions()[0]).toEqual(remixAction);
+ test("Save button renders", () => {
+ expect(screen.queryByText("header.save")).toBeInTheDocument();
});
-});
-describe("When not logged in", () => {
- let store;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: project,
- loading: "success",
- },
- auth: {
- user: null,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- });
-
- test("Clicking save opens login to save modal", () => {
- const saveButton = screen.getByText("header.save");
+ test("Clicking save dispatches trigger save action", () => {
+ const saveButton = screen.queryByText("header.save");
fireEvent.click(saveButton);
- expect(store.getActions()).toEqual([showLoginToSaveModal()]);
+ expect(store.getActions()).toEqual([triggerSave()]);
});
});
-describe("When no project loaded", () => {
+describe("When project is not loaded", () => {
beforeEach(() => {
const middlewares = [];
const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {},
- loading: "idle",
- },
- auth: {
- user: user,
- },
- };
- const store = mockStore(initialState);
+ const store = mockStore({
+ editor: {},
+ });
render(
-
-
-
+
,
);
});
-
- test("No save button", () => {
+ test("Does not render save button", () => {
expect(screen.queryByText("header.save")).not.toBeInTheDocument();
});
});
diff --git a/src/containers/ProjectComponentLoader.jsx b/src/containers/ProjectComponentLoader.jsx
index 999df3f32..6b3db0e5c 100644
--- a/src/containers/ProjectComponentLoader.jsx
+++ b/src/containers/ProjectComponentLoader.jsx
@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
-import { useDispatch, useSelector } from "react-redux";
+import { useSelector } from "react-redux";
import { useProject } from "../hooks/useProject";
import { useEmbeddedMode } from "../hooks/useEmbeddedMode";
import { useMediaQuery } from "react-responsive";
@@ -8,14 +8,6 @@ import { useTranslation } from "react-i18next";
import { MOBILE_MEDIA_QUERY } from "../utils/mediaQueryBreakpoints";
-import {
- expireJustLoaded,
- setHasShownSavePrompt,
- syncProject,
-} from "../redux/EditorSlice";
-import { isOwner } from "../utils/projectHelpers";
-import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";
-
import Project from "../components/Editor/Project/Project";
import MobileProject from "../components/Mobile/MobileProject/MobileProject";
import NewFileModal from "../components/Modals/NewFileModal";
@@ -23,6 +15,7 @@ import NotFoundModal from "../components/Modals/NotFoundModal";
import AccessDeniedNoAuthModal from "../components/Modals/AccessDeniedNoAuthModal";
import AccessDeniedWithAuthModal from "../components/Modals/AccessDeniedWithAuthModal";
import RenameFileModal from "../components/Modals/RenameFileModal";
+import { useProjectPersistence } from "../hooks/useProjectPersistence";
const ProjectComponentLoader = (props) => {
const loading = useSelector((state) => state.editor.loading);
@@ -35,6 +28,7 @@ const ProjectComponentLoader = (props) => {
const hasShownSavePrompt = useSelector(
(state) => state.editor.hasShownSavePrompt,
);
+ const saveTriggered = useSelector((state) => state.editor.saveTriggered);
const modals = useSelector((state) => state.editor.modals);
const newFileModalShowing = useSelector(
@@ -54,12 +48,18 @@ const ProjectComponentLoader = (props) => {
);
const navigate = useNavigate();
- const dispatch = useDispatch();
const { t, i18n } = useTranslation();
const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY });
useEmbeddedMode(embedded);
useProject({ projectIdentifier: identifier, accessToken: accessToken });
+ useProjectPersistence({
+ user,
+ project,
+ justLoaded,
+ hasShownSavePrompt,
+ saveTriggered,
+ });
useEffect(() => {
if (loading === "idle" && project.identifier) {
@@ -70,55 +70,6 @@ const ProjectComponentLoader = (props) => {
}
}, [loading, project, i18n.language, navigate]);
- useEffect(() => {
- if (user && localStorage.getItem("awaitingSave")) {
- if (isOwner(user, project)) {
- dispatch(
- syncProject("save")({
- project,
- accessToken: user.access_token,
- autosave: false,
- }),
- );
- } else if (user && project.identifier) {
- dispatch(
- syncProject("remix")({ project, accessToken: user.access_token }),
- );
- }
- localStorage.removeItem("awaitingSave");
- return;
- }
- let debouncer = setTimeout(() => {
- if (isOwner(user, project) && project.identifier) {
- if (justLoaded) {
- dispatch(expireJustLoaded());
- }
- dispatch(
- syncProject("save")({
- project,
- accessToken: user.access_token,
- autosave: true,
- }),
- );
- } else {
- if (justLoaded) {
- dispatch(expireJustLoaded());
- } else {
- localStorage.setItem(
- project.identifier || "project",
- JSON.stringify(project),
- );
- if (!hasShownSavePrompt) {
- user ? showSavePrompt() : showLoginPrompt();
- dispatch(setHasShownSavePrompt());
- }
- }
- }
- }, 2000);
-
- return () => clearTimeout(debouncer);
- }, [dispatch, project, user, hasShownSavePrompt, justLoaded]);
-
return (
<>
{loading === "success" ? (
diff --git a/src/containers/ProjectComponentLoader.test.js b/src/containers/ProjectComponentLoader.test.js
index 45c93674f..e79dd48f8 100644
--- a/src/containers/ProjectComponentLoader.test.js
+++ b/src/containers/ProjectComponentLoader.test.js
@@ -1,5 +1,5 @@
import React from "react";
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import { MemoryRouter } from "react-router-dom";
@@ -8,19 +8,11 @@ import { defaultPythonProject } from "../utils/defaultProjects";
import { matchMedia, setMedia } from "mock-match-media";
import { MOBILE_BREAKPOINT } from "../utils/mediaQueryBreakpoints";
-import {
- setProject,
- expireJustLoaded,
- setHasShownSavePrompt,
- syncProject,
-} from "../redux/EditorSlice";
-import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";
+import { setProject } from "../redux/EditorSlice";
+import { useProjectPersistence } from "../hooks/useProjectPersistence";
-jest.mock("axios");
-
-jest.mock("react-router-dom", () => ({
- ...jest.requireActual("react-router-dom"),
- useNavigate: () => jest.fn(),
+jest.mock("../hooks/useProjectPersistence", () => ({
+ useProjectPersistence: jest.fn(),
}));
jest.mock("react-responsive", () => ({
@@ -28,49 +20,19 @@ jest.mock("react-responsive", () => ({
useMediaQuery: ({ query }) => mockMediaQuery(query),
}));
-jest.mock("../redux/EditorSlice", () => ({
- ...jest.requireActual("../redux/EditorSlice"),
- syncProject: jest.fn((_) => jest.fn()),
-}));
-
-jest.mock("../utils/Notifications");
-
-jest.useFakeTimers();
-
let mockMediaQuery = (query) => {
return matchMedia(query).matches;
};
window.HTMLElement.prototype.scrollIntoView = jest.fn();
-const user1 = {
+const user = {
access_token: "myAccessToken",
profile: {
user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf",
},
};
-const user2 = {
- access_token: "myAccessToken",
- profile: {
- user: "cd8a5b3d-f7bb-425e-908f-1386decd6bb1",
- },
-};
-
-const project = {
- name: "hello world",
- project_type: "python",
- identifier: "hello-world-project",
- components: [
- {
- name: "main",
- extension: "py",
- content: "# hello",
- },
- ],
- user_id: user1.profile.user,
-};
-
test("Renders loading message if loading is pending", () => {
const middlewares = [];
const mockStore = configureStore(middlewares);
@@ -138,6 +100,65 @@ test("Does not render loading message if loading is success", () => {
expect(screen.queryByText("project.loading")).not.toBeInTheDocument();
});
+test("Calls useProjectPersistence with user when logged in", () => {
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {},
+ hasShownSavePrompt: true,
+ justLoaded: false,
+ saveTriggered: false,
+ },
+ auth: { user },
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+
+
+
+ ,
+ );
+ expect(useProjectPersistence).toHaveBeenCalledWith({
+ user,
+ project: {},
+ hasShownSavePrompt: true,
+ justLoaded: false,
+ saveTriggered: false,
+ });
+});
+
+test("Calls useProjectPersistence without user when logged in", () => {
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {},
+ hasShownSavePrompt: true,
+ justLoaded: false,
+ saveTriggered: false,
+ },
+ auth: {},
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+
+
+
+ ,
+ );
+ expect(useProjectPersistence).toHaveBeenCalledWith({
+ project: {},
+ hasShownSavePrompt: true,
+ justLoaded: false,
+ saveTriggered: false,
+ });
+});
+
describe("When on mobile", () => {
let mockStore;
@@ -203,476 +224,3 @@ describe("When on mobile", () => {
expect(screen.queryByText("mobile.preview")).toBeInTheDocument();
});
});
-
-describe("When not logged in and just loaded", () => {
- let mockedStore;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- justLoaded: true,
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {},
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Expires justLoaded", async () => {
- const expectedActions = [
- setProject(defaultPythonProject),
- expireJustLoaded(),
- ];
- await waitFor(
- () => expect(mockedStore.getActions()).toEqual(expectedActions),
- { timeout: 2100 },
- );
- });
-});
-
-describe("When not logged in and not just loaded", () => {
- let mockedStore;
- let expectedActions;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- justLoaded: false,
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {},
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- expectedActions = [setProject(defaultPythonProject)];
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Login prompt shown", async () => {
- await waitFor(() => expect(showLoginPrompt).toHaveBeenCalled(), {
- timeout: 2100,
- });
- });
-
- test("Dispatches save prompt shown action", async () => {
- expectedActions.push(setHasShownSavePrompt());
- await waitFor(
- () => expect(mockedStore.getActions()).toEqual(expectedActions),
- { timeout: 2100 },
- );
- });
-
- test("Project saved in localStorage", async () => {
- await waitFor(
- () =>
- expect(localStorage.getItem("hello-world-project")).toEqual(
- JSON.stringify(project),
- ),
- { timeout: 2100 },
- );
- });
-});
-
-describe("When not logged in and has been prompted to login to save", () => {
- let mockedStore;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- justLoaded: false,
- hasShownSavePrompt: true,
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {},
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Login prompt shown", async () => {
- jest.runAllTimers();
- await waitFor(() => expect(showLoginPrompt).not.toHaveBeenCalled(), {
- timeout: 2100,
- });
- });
-
- test("Project saved in localStorage", async () => {
- await waitFor(
- () =>
- expect(localStorage.getItem("hello-world-project")).toEqual(
- JSON.stringify(project),
- ),
- { timeout: 2100 },
- );
- });
-});
-
-describe("When logged in and user does not own project and just loaded", () => {
- let mockedStore;
- let expectedActions;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- justLoaded: true,
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {
- user: user2,
- },
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- expectedActions = [setProject(defaultPythonProject)];
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Expires justLoaded", async () => {
- expectedActions.push(expireJustLoaded());
- await waitFor(
- () => expect(mockedStore.getActions()).toEqual(expectedActions),
- { timeout: 2100 },
- );
- });
-});
-
-describe("When logged in and user does not own project and not just loaded", () => {
- let mockedStore;
- let expectedActions;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- justLoaded: false,
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {
- user: user2,
- },
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- expectedActions = [setProject(defaultPythonProject)];
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Save prompt shown", async () => {
- await waitFor(() => expect(showSavePrompt).toHaveBeenCalled(), {
- timeout: 2100,
- });
- });
-
- test("Dispatches save prompt shown action", async () => {
- expectedActions.push(setHasShownSavePrompt());
- await waitFor(
- () => expect(mockedStore.getActions()).toEqual(expectedActions),
- { timeout: 2100 },
- );
- });
-
- test("Project saved in localStorage", async () => {
- await waitFor(
- () =>
- expect(localStorage.getItem("hello-world-project")).toEqual(
- JSON.stringify(project),
- ),
- { timeout: 2100 },
- );
- });
-});
-
-describe("When logged in and user does not own project and prompted to save", () => {
- let mockedStore;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- justLoaded: false,
- hasShownSavePrompt: true,
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {
- user: user2,
- },
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Save prompt not shown again", async () => {
- jest.runAllTimers();
- await waitFor(() => expect(showSavePrompt).not.toHaveBeenCalled(), {
- timeout: 2100,
- });
- });
-
- test("Project saved in localStorage", async () => {
- await waitFor(
- () =>
- expect(localStorage.getItem("hello-world-project")).toEqual(
- JSON.stringify(project),
- ),
- { timeout: 2100 },
- );
- });
-});
-
-describe("When logged in and user does not own project and awaiting save", () => {
- let mockedStore;
- let remixProject;
- let remixAction;
- let expectedActions;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {
- user: user2,
- },
- };
- mockedStore = mockStore(initialState);
- localStorage.setItem("awaitingSave", "true");
- remixAction = { type: "REMIX_PROJECT" };
- remixProject = jest.fn(() => remixAction);
- syncProject.mockImplementationOnce(jest.fn((_) => remixProject));
- render(
-
-
-
-
- ,
- );
- expectedActions = [setProject(defaultPythonProject)];
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Project remixed and saved to database", async () => {
- expectedActions.push(remixAction);
- await waitFor(
- () =>
- expect(remixProject).toHaveBeenCalledWith({
- project,
- accessToken: user2.access_token,
- }),
- { timeout: 2100 },
- );
- expect(mockedStore.getActions()).toEqual(expectedActions);
- });
-});
-
-describe("When logged in and project has no identifier and awaiting save", () => {
- let mockedStore;
- let saveProject;
- let saveAction;
- let expectedActions;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: { ...project, identifier: null },
- loading: "success",
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {
- user: user2,
- },
- };
- mockedStore = mockStore(initialState);
- localStorage.setItem("awaitingSave", "true");
- saveAction = { type: "SAVE_PROJECT" };
- saveProject = jest.fn(() => saveAction);
- syncProject.mockImplementationOnce(jest.fn((_) => saveProject));
- render(
-
-
-
-
- ,
- );
- expectedActions = [setProject(defaultPythonProject)];
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("Project saved to database", async () => {
- expectedActions.push(saveAction);
- await waitFor(
- () =>
- expect(saveProject).toHaveBeenCalledWith({
- project: { ...project, identifier: null },
- accessToken: user2.access_token,
- autosave: false,
- }),
- { timeout: 2100 },
- );
- expect(mockedStore.getActions()).toEqual(expectedActions);
- });
-});
-
-describe("When logged in and user owns project", () => {
- let mockedStore;
- let expectedActions;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project,
- loading: "success",
- openFiles: [[]],
- focussedFileIndices: [0],
- },
- auth: {
- user: user1,
- },
- };
- mockedStore = mockStore(initialState);
- render(
-
-
-
-
- ,
- );
- expectedActions = [setProject(defaultPythonProject)];
- });
-
- test("Project autosaved to database", async () => {
- const saveAction = { type: "SAVE_PROJECT" };
- const saveProject = jest.fn(() => saveAction);
- expectedActions.push(saveAction);
- syncProject.mockImplementationOnce(jest.fn((_) => saveProject));
- await waitFor(
- () =>
- expect(saveProject).toHaveBeenCalledWith({
- project,
- accessToken: user1.access_token,
- autosave: true,
- }),
- { timeout: 2100 },
- );
- expect(mockedStore.getActions()).toEqual(expectedActions);
- });
-});
diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx
index ccb14d4eb..09cddd157 100644
--- a/src/containers/WebComponentLoader.jsx
+++ b/src/containers/WebComponentLoader.jsx
@@ -1,24 +1,64 @@
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
-import { setProject, setSenseHatAlwaysEnabled } from "../redux/EditorSlice";
+import { setSenseHatAlwaysEnabled } from "../redux/EditorSlice";
import WebComponentProject from "../components/WebComponentProject/WebComponentProject";
import { useTranslation } from "react-i18next";
import { setInstructions } from "../redux/InstructionsSlice";
+import { useProject } from "../hooks/useProject";
+import { useProjectPersistence } from "../hooks/useProjectPersistence";
+import { removeUser, setUser } from "../redux/WebComponentAuthSlice";
const WebComponentLoader = (props) => {
const loading = useSelector((state) => state.editor.loading);
- const { code, senseHatAlwaysEnabled = false, instructions } = props;
+ const {
+ authKey,
+ identifier,
+ code,
+ senseHatAlwaysEnabled = false,
+ instructions,
+ } = props;
const dispatch = useDispatch();
const { t } = useTranslation();
+ const [projectIdentifier, setProjectIdentifier] = useState(identifier);
+ const project = useSelector((state) => state.editor.project);
+ const user = JSON.parse(localStorage.getItem(authKey));
+ const justLoaded = useSelector((state) => state.editor.justLoaded);
+ const hasShownSavePrompt = useSelector(
+ (state) => state.editor.hasShownSavePrompt,
+ );
+ const saveTriggered = useSelector((state) => state.editor.saveTriggered);
+
+ useEffect(() => {
+ if (user) {
+ dispatch(setUser(user));
+ } else {
+ dispatch(removeUser());
+ }
+ }, [user, dispatch]);
+
+ useEffect(() => {
+ if (loading === "idle" && project.identifier) {
+ setProjectIdentifier(project.identifier);
+ }
+ }, [loading, project]);
+
+ useProject({
+ projectIdentifier: projectIdentifier,
+ code,
+ accessToken: user && user.access_token,
+ });
+
+ useProjectPersistence({
+ user,
+ project,
+ justLoaded,
+ hasShownSavePrompt,
+ saveTriggered,
+ });
useEffect(() => {
- const proj = {
- type: "python",
- components: [{ name: "main", extension: "py", content: code }],
- };
dispatch(setSenseHatAlwaysEnabled(senseHatAlwaysEnabled));
- dispatch(setProject(proj));
- }, [code, senseHatAlwaysEnabled, dispatch]);
+ }, [senseHatAlwaysEnabled, dispatch]);
useEffect(() => {
if (instructions) {
diff --git a/src/containers/WebComponentLoader.test.js b/src/containers/WebComponentLoader.test.js
index 560b23dee..bc6e23866 100644
--- a/src/containers/WebComponentLoader.test.js
+++ b/src/containers/WebComponentLoader.test.js
@@ -3,15 +3,29 @@ import React from "react";
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import WebComponentLoader from "./WebComponentLoader";
-import { setProject, setSenseHatAlwaysEnabled } from "../redux/EditorSlice";
+import { setSenseHatAlwaysEnabled } from "../redux/EditorSlice";
import { setInstructions } from "../redux/InstructionsSlice";
+import { useProject } from "../hooks/useProject";
+import { useProjectPersistence } from "../hooks/useProjectPersistence";
+
+jest.mock("../hooks/useProject", () => ({
+ useProject: jest.fn(),
+}));
+
+jest.mock("../hooks/useProjectPersistence", () => ({
+ useProjectPersistence: jest.fn(),
+}));
let store;
const code = "print('This project is amazing')";
+const identifier = "My amazing project";
const steps = [{ quiz: false, title: "Step 1", content: "Do something" }];
const instructions = { currentStepPosition: 3, project: { steps: steps } };
+const authKey = "my_key";
+const user = { access_token: "my_token" };
beforeEach(() => {
+ localStorage.setItem(authKey, JSON.stringify(user));
const middlewares = [];
const mockStore = configureStore(middlewares);
const initialState = {
@@ -32,21 +46,28 @@ beforeEach(() => {
,
);
});
-test("Sets project with code from attribute", () => {
- const project = {
- type: "python",
- components: [{ name: "main", extension: "py", content: code }],
- };
- expect(store.getActions()).toEqual(
- expect.arrayContaining([setProject(project)]),
- );
+test("Calls useProject hook with correct attributes", () => {
+ expect(useProject).toHaveBeenCalledWith({
+ projectIdentifier: identifier,
+ code,
+ accessToken: "my_token",
+ });
+});
+
+test("Calls useProjectPersistence hook with correct attributes", () => {
+ expect(useProjectPersistence).toHaveBeenCalledWith({
+ user,
+ project: { components: [] },
+ });
});
test("Enables the SenseHat", () => {
@@ -60,3 +81,7 @@ test("Sets the instructions", () => {
expect.arrayContaining([setInstructions(instructions)]),
);
});
+
+afterEach(() => {
+ localStorage.clear();
+});
diff --git a/src/hooks/useProject.js b/src/hooks/useProject.js
index e36178cb2..a51cc9ef4 100644
--- a/src/hooks/useProject.js
+++ b/src/hooks/useProject.js
@@ -4,16 +4,16 @@ import { useDispatch } from "react-redux";
import { syncProject, setProject } from "../redux/EditorSlice";
import { defaultPythonProject } from "../utils/defaultProjects";
import { useTranslation } from "react-i18next";
-import { useSearchParams } from "react-router-dom";
export const useProject = ({
projectIdentifier = null,
+ code = null,
accessToken = null,
isEmbedded = false,
+ isBrowserPreview = false,
}) => {
- const [searchParams] = useSearchParams();
const getCachedProject = (id) =>
- isEmbedded && searchParams.get("browserPreview") !== "true"
+ isEmbedded && !isBrowserPreview
? null
: JSON.parse(localStorage.getItem(id || "project"));
const [cachedProject, setCachedProject] = useState(
@@ -52,6 +52,17 @@ export const useProject = ({
);
return;
}
+
+ if (code) {
+ const project = {
+ name: "Blank project",
+ type: "python",
+ components: [{ name: "main", extension: "py", content: code }],
+ };
+ dispatch(setProject(project));
+ return;
+ }
+
const data = defaultPythonProject;
dispatch(setProject(data));
}, [projectIdentifier, cachedProject, i18n.language, accessToken]);
diff --git a/src/hooks/useProject.test.js b/src/hooks/useProject.test.js
index 4a4724d12..d7e8384f1 100644
--- a/src/hooks/useProject.test.js
+++ b/src/hooks/useProject.test.js
@@ -4,22 +4,11 @@ import { syncProject, setProject } from "../redux/EditorSlice";
import { waitFor } from "@testing-library/react";
import { defaultPythonProject } from "../utils/defaultProjects";
-let mockBrowserPreview = "false";
-
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: () => jest.fn(),
}));
-jest.mock("react-router-dom", () => ({
- ...jest.requireActual("react-router-dom"),
- useSearchParams: () => [
- {
- get: (key) => (key === "browserPreview" ? mockBrowserPreview : null),
- },
- ],
-}));
-
const loadProject = jest.fn();
jest.mock("../redux/EditorSlice");
@@ -125,18 +114,34 @@ test("If embedded and cached project, loads from server", async () => {
});
test("If new tab browser preview, uses cached changes", () => {
- mockBrowserPreview = "true";
localStorage.setItem("hello-world-project", JSON.stringify(cachedProject));
renderHook(() =>
useProject({
projectIdentifier: "hello-world-project",
accessToken,
isEmbedded: true,
+ isBrowserPreview: true,
}),
);
expect(setProject).toHaveBeenCalledWith(cachedProject);
});
+test("If no identifier or cached project, uses code attribute", () => {
+ const code = "print('hello world')";
+ const expectedProject = {
+ name: "Blank project",
+ type: "python",
+ components: [{ name: "main", extension: "py", content: code }],
+ };
+ renderHook(() =>
+ useProject({
+ code,
+ accessToken,
+ }),
+ );
+ expect(setProject).toHaveBeenCalledWith(expectedProject);
+});
+
afterEach(() => {
localStorage.clear();
});
diff --git a/src/hooks/useProjectPersistence.js b/src/hooks/useProjectPersistence.js
new file mode 100644
index 000000000..c26a3136d
--- /dev/null
+++ b/src/hooks/useProjectPersistence.js
@@ -0,0 +1,89 @@
+import { useEffect } from "react";
+import { useDispatch } from "react-redux";
+import { isOwner } from "../utils/projectHelpers";
+import {
+ expireJustLoaded,
+ setHasShownSavePrompt,
+ showLoginToSaveModal,
+ syncProject,
+} from "../redux/EditorSlice";
+import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";
+
+export const useProjectPersistence = ({
+ user,
+ project,
+ justLoaded,
+ hasShownSavePrompt,
+ saveTriggered,
+}) => {
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ if (saveTriggered) {
+ if (isOwner(user, project)) {
+ dispatch(
+ syncProject("save")({
+ project,
+ accessToken: user.access_token,
+ autosave: false,
+ }),
+ );
+ } else if (user && project.identifier) {
+ dispatch(
+ syncProject("remix")({ project, accessToken: user.access_token }),
+ );
+ } else {
+ dispatch(showLoginToSaveModal());
+ }
+ }
+ }, [saveTriggered, project, user, dispatch]);
+
+ useEffect(() => {
+ if (user && localStorage.getItem("awaitingSave")) {
+ if (isOwner(user, project)) {
+ dispatch(
+ syncProject("save")({
+ project,
+ accessToken: user.access_token,
+ autosave: false,
+ }),
+ );
+ } else if (user && project.identifier) {
+ dispatch(
+ syncProject("remix")({ project, accessToken: user.access_token }),
+ );
+ }
+ localStorage.removeItem("awaitingSave");
+ return;
+ }
+ let debouncer = setTimeout(() => {
+ if (isOwner(user, project) && project.identifier) {
+ if (justLoaded) {
+ dispatch(expireJustLoaded());
+ }
+ dispatch(
+ syncProject("save")({
+ project,
+ accessToken: user.access_token,
+ autosave: true,
+ }),
+ );
+ } else {
+ if (justLoaded) {
+ dispatch(expireJustLoaded());
+ } else {
+ localStorage.setItem(
+ project.identifier || "project",
+ JSON.stringify(project),
+ );
+ if (!hasShownSavePrompt) {
+ user ? showSavePrompt() : showLoginPrompt();
+ dispatch(setHasShownSavePrompt());
+ }
+ }
+ }
+ }, 2000);
+
+ return () => clearTimeout(debouncer);
+ }, [dispatch, project, user, hasShownSavePrompt, justLoaded]);
+};
diff --git a/src/hooks/useProjectPersistence.test.js b/src/hooks/useProjectPersistence.test.js
new file mode 100644
index 000000000..b56b74b26
--- /dev/null
+++ b/src/hooks/useProjectPersistence.test.js
@@ -0,0 +1,338 @@
+import { renderHook } from "@testing-library/react";
+import { useProjectPersistence } from "./useProjectPersistence";
+import {
+ expireJustLoaded,
+ setHasShownSavePrompt,
+ showLoginToSaveModal,
+ syncProject,
+} from "../redux/EditorSlice";
+import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";
+
+jest.mock("react-redux", () => ({
+ ...jest.requireActual("react-redux"),
+ useDispatch: () => jest.fn(),
+}));
+
+jest.mock("../redux/EditorSlice", () => ({
+ ...jest.requireActual("../redux/EditorSlice"),
+ syncProject: jest.fn((_) => jest.fn()),
+ expireJustLoaded: jest.fn(),
+ setHasShownSavePrompt: jest.fn(),
+ showLoginToSaveModal: jest.fn(),
+}));
+
+jest.mock("../utils/Notifications");
+
+const remixAction = { type: "REMIX_PROJECT" };
+const remixProject = jest.fn(() => remixAction);
+const saveAction = { type: "SAVE_PROJECT" };
+const saveProject = jest.fn(() => saveAction);
+
+jest.useFakeTimers();
+
+const user1 = {
+ access_token: "myAccessToken1",
+ profile: {
+ user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf",
+ },
+};
+
+const user2 = {
+ access_token: "myAccessToken2",
+ profile: {
+ user: "cd8a5b3d-f7bb-425e-908f-1386decd6bb1",
+ },
+};
+
+const project = {
+ name: "hello world",
+ project_type: "python",
+ identifier: "hello-world-project",
+ components: [
+ {
+ name: "main",
+ extension: "py",
+ content: "# hello",
+ },
+ ],
+ user_id: user1.profile.user,
+};
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("When not logged in", () => {
+ describe("When just loaded", () => {
+ beforeEach(() => {
+ renderHook(() => useProjectPersistence({ user: null, justLoaded: true }));
+ jest.runAllTimers();
+ });
+
+ test("Expires justLoaded", () => {
+ expect(expireJustLoaded).toHaveBeenCalled();
+ });
+ });
+
+ describe("When not just loaded", () => {
+ beforeEach(() => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: null,
+ project: project,
+ justLoaded: false,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Login prompt shown", () => {
+ expect(showLoginPrompt).toHaveBeenCalled();
+ });
+
+ test("Dispatches save prompt shown action", () => {
+ expect(setHasShownSavePrompt).toHaveBeenCalled();
+ });
+
+ test("Project saved in localStorage", () => {
+ expect(localStorage.getItem("hello-world-project")).toEqual(
+ JSON.stringify(project),
+ );
+ });
+ });
+
+ describe("When has been prompted to login to save", () => {
+ beforeEach(() => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: null,
+ project: project,
+ justLoaded: false,
+ hasShownSavePrompt: true,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Login prompt shown", () => {
+ expect(showLoginPrompt).not.toHaveBeenCalled();
+ });
+
+ test("Project saved in localStorage", () => {
+ expect(localStorage.getItem("hello-world-project")).toEqual(
+ JSON.stringify(project),
+ );
+ });
+ });
+
+ describe("When save has been triggered", () => {
+ beforeEach(() => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: null,
+ project: project,
+ saveTriggered: true,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Login to save modal is shown", () => {
+ expect(showLoginToSaveModal).toHaveBeenCalled();
+ });
+ });
+});
+
+describe("When logged in", () => {
+ describe("When user does not own project", () => {
+ describe("When just loaded", () => {
+ beforeEach(() => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: project,
+ justLoaded: true,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Expires justLoaded", () => {
+ expect(expireJustLoaded).toHaveBeenCalled();
+ });
+ });
+
+ describe("When not just loaded", () => {
+ beforeEach(() => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: project,
+ justLoaded: false,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Save prompt shown", () => {
+ expect(showSavePrompt).toHaveBeenCalled();
+ });
+
+ test("Dispatches save prompt shown action", () => {
+ expect(setHasShownSavePrompt).toHaveBeenCalled();
+ });
+
+ test("Project saved in localStorage", () => {
+ expect(localStorage.getItem("hello-world-project")).toEqual(
+ JSON.stringify(project),
+ );
+ });
+ });
+
+ describe("When user has been prompted to save", () => {
+ beforeEach(() => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: project,
+ justLoaded: false,
+ hasShownSavePrompt: true,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Save prompt not shown again", () => {
+ expect(showSavePrompt).not.toHaveBeenCalled();
+ });
+
+ test("Project saved in localStorage", () => {
+ expect(localStorage.getItem("hello-world-project")).toEqual(
+ JSON.stringify(project),
+ );
+ });
+ });
+
+ describe("When project has identifier and awaiting save", () => {
+ beforeEach(() => {
+ localStorage.setItem("awaitingSave", "true");
+ syncProject.mockImplementationOnce(jest.fn((_) => remixProject));
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: project,
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Project remixed and saved to database", () => {
+ expect(remixProject).toHaveBeenCalledWith({
+ project,
+ accessToken: user2.access_token,
+ });
+ });
+ });
+
+ describe("When project has identifier and save triggered", () => {
+ beforeEach(() => {
+ syncProject.mockImplementationOnce(jest.fn((_) => remixProject));
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: project,
+ saveTriggered: true,
+ }),
+ );
+ jest.runAllTimers();
+ });
+ test("Clicking save dispatches remixProject with correct parameters", async () => {
+ expect(remixProject).toHaveBeenCalledWith({
+ project: project,
+ accessToken: user2.access_token,
+ });
+ });
+ });
+
+ describe("When project has no identifier and awaiting save", () => {
+ beforeEach(() => {
+ localStorage.setItem("awaitingSave", "true");
+ syncProject.mockImplementationOnce(jest.fn((_) => saveProject));
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: { ...project, identifier: null },
+ }),
+ );
+ jest.runAllTimers();
+ });
+
+ test("Project saved to database", () => {
+ expect(saveProject).toHaveBeenCalledWith({
+ project: { ...project, identifier: null },
+ accessToken: user2.access_token,
+ autosave: false,
+ });
+ });
+ });
+
+ describe("When project has no identifier and save triggered", () => {
+ beforeEach(() => {
+ syncProject.mockImplementationOnce(jest.fn((_) => saveProject));
+ renderHook(() =>
+ useProjectPersistence({
+ user: user2,
+ project: { ...project, identifier: null },
+ saveTriggered: true,
+ }),
+ );
+ jest.runAllTimers();
+ });
+ test("Save project is called with the correct parameters", () => {
+ expect(saveProject).toHaveBeenCalledWith({
+ project: { ...project, identifier: null },
+ accessToken: user2.access_token,
+ autosave: false,
+ });
+ });
+ });
+ });
+
+ describe("When user owns project", () => {
+ beforeEach(() => {
+ syncProject.mockImplementation(jest.fn((_) => saveProject));
+ });
+
+ test("Project autosaved to database if save not triggered", async () => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: user1,
+ project: project,
+ saveTriggered: false,
+ }),
+ );
+ jest.runAllTimers();
+ expect(saveProject).toHaveBeenCalledWith({
+ project,
+ accessToken: user1.access_token,
+ autosave: true,
+ });
+ });
+
+ test("Saves project to database if save triggered", async () => {
+ renderHook(() =>
+ useProjectPersistence({
+ user: user1,
+ project: project,
+ saveTriggered: true,
+ }),
+ );
+ jest.runAllTimers();
+ expect(saveProject).toHaveBeenCalledWith({
+ project,
+ accessToken: user1.access_token,
+ autosave: false,
+ });
+ });
+ });
+});
diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js
index 25481dcd1..cdb745fd4 100644
--- a/src/redux/EditorSlice.js
+++ b/src/redux/EditorSlice.js
@@ -76,6 +76,7 @@ export const EditorSlice = createSlice({
name: "editor",
initialState: {
project: {},
+ saveTriggered: false,
saving: "idle",
loading: "idle",
justLoaded: false,
@@ -208,6 +209,9 @@ export const EditorSlice = createSlice({
triggerDraw: (state) => {
state.drawTriggered = true;
},
+ triggerSave: (state) => {
+ state.saveTriggered = true;
+ },
updateProjectComponent: (state, action) => {
const extension = action.payload.extension;
const fileName = action.payload.name;
@@ -283,6 +287,7 @@ export const EditorSlice = createSlice({
},
closeLoginToSaveModal: (state) => {
state.loginToSaveModalShowing = false;
+ state.saveTriggered = false;
},
closeNotFoundModal: (state) => {
state.notFoundModalShowing = false;
@@ -338,6 +343,7 @@ export const EditorSlice = createSlice({
extraReducers: (builder) => {
builder.addCase("editor/saveProject/pending", (state) => {
state.saving = "pending";
+ state.saveTriggered = false;
});
builder.addCase("editor/saveProject/fulfilled", (state, action) => {
localStorage.removeItem(state.project.identifier || "project");
@@ -417,6 +423,7 @@ export const {
stopDraw,
triggerCodeRun,
triggerDraw,
+ triggerSave,
updateComponentName,
updateImages,
updateProjectComponent,
diff --git a/src/redux/WebComponentAuthSlice.js b/src/redux/WebComponentAuthSlice.js
new file mode 100644
index 000000000..43579db30
--- /dev/null
+++ b/src/redux/WebComponentAuthSlice.js
@@ -0,0 +1,11 @@
+import { createSlice } from "@reduxjs/toolkit";
+import { reducers } from "./reducers/webComponentAuthReducers";
+
+export const WebComponentAuthSlice = createSlice({
+ name: "auth",
+ initialState: {},
+ reducers,
+});
+
+export const { setUser, removeUser } = WebComponentAuthSlice.actions;
+export default WebComponentAuthSlice.reducer;
diff --git a/src/redux/reducers/webComponentAuthReducers.js b/src/redux/reducers/webComponentAuthReducers.js
new file mode 100644
index 000000000..0005d2e2d
--- /dev/null
+++ b/src/redux/reducers/webComponentAuthReducers.js
@@ -0,0 +1,9 @@
+export const setUser = (state, action) => {
+ state.user = action.payload;
+};
+
+export const removeUser = (state) => {
+ state.user = null;
+};
+
+export const reducers = { setUser, removeUser };
diff --git a/src/redux/reducers/webComponentAuthReducers.test.js b/src/redux/reducers/webComponentAuthReducers.test.js
new file mode 100644
index 000000000..3a06726a7
--- /dev/null
+++ b/src/redux/reducers/webComponentAuthReducers.test.js
@@ -0,0 +1,20 @@
+import { setUser, removeUser } from "./webComponentAuthReducers";
+
+const user = {
+ access_token: "my_token",
+};
+
+test("Sets user correctly", () => {
+ let state = {};
+ const expectedState = { user };
+ setUser(state, { payload: user });
+ expect(state).toEqual(expectedState);
+});
+
+test("Removes user correctly", () => {
+ let state = { user };
+ const expectedState = { user: null };
+
+ removeUser(state);
+ expect(state).toEqual(expectedState);
+});
diff --git a/src/web-component.js b/src/web-component.js
index c45552f8a..0c351aadd 100644
--- a/src/web-component.js
+++ b/src/web-component.js
@@ -4,7 +4,7 @@ import * as ReactDOMClient from "react-dom/client";
import * as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";
import WebComponentLoader from "./containers/WebComponentLoader";
-import store from "./app/store";
+import store from "./app/WebComponentStore";
import { Provider } from "react-redux";
import "./utils/i18n";
import camelCase from "camelcase";
@@ -35,7 +35,13 @@ class WebComponent extends HTMLElement {
}
static get observedAttributes() {
- return ["code", "sense_hat_always_enabled", "instructions"];
+ return [
+ "auth_key",
+ "identifier",
+ "code",
+ "sense_hat_always_enabled",
+ "instructions",
+ ];
}
attributeChangedCallback(name, _oldVal, newVal) {