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) {