- {instructionsEditable && (
-
+
+ {instructionsEditable ? (
+ hasInstructions ? (
+
+ {instructionsEditable && (
+
+ )}
+
+ ) : (
+
+
+ {t("instructionsPanel.emptyState.purpose")}
+
+
+ {t("instructionsPanel.emptyState.location")}
+
+
+ {() => (
+ ,
+ ]}
+ />
+ )}
+
+
+ {t("instructionsPanel.emptyState.edits")}
+
+
+ )
+ ) : (
+
)}
-
);
};
diff --git a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.js b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.js
index ec9dbf53e..d21a4ca05 100644
--- a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.js
+++ b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.js
@@ -3,234 +3,311 @@ import InstructionsPanel from "./InstructionsPanel";
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import { setProjectInstructions } from "../../../../redux/EditorSlice";
+import { act } from "react";
+
window.HTMLElement.prototype.scrollTo = jest.fn();
window.Prism = {
highlightElement: jest.fn(),
};
-describe("It renders project steps when there is no quiz", () => {
- beforeEach(() => {
- const mockStore = configureStore([]);
- const initialState = {
- editor: {
- project: {},
- instructionsEditable: false,
- },
- instructions: {
- project: {
- steps: [
- { content: "
step 0
" },
- {
- content: `
step 1
-
print('hello')
-
Hello world
-
.hello { color: purple }
- `,
- },
- ],
- },
- quiz: {},
- currentStepPosition: 1,
- },
- };
- const store = mockStore(initialState);
- render(
-
-
- ,
- );
- });
+describe("When instructionsEditable is true", () => {
+ describe("When there are instructions", () => {
+ let store;
- test("Renders with correct instruction step content", () => {
- expect(screen.queryByText("step 1")).toBeInTheDocument();
- });
+ beforeEach(() => {
+ const mockStore = configureStore([]);
+ const initialState = {
+ editor: {
+ project: {},
+ instructionsEditable: true,
+ },
+ instructions: {
+ project: {
+ steps: [{ content: "instructions" }],
+ },
+ quiz: {},
+ currentStepPosition: 1,
+ },
+ };
+ store = mockStore(initialState);
+ render(
+
+
+ ,
+ );
+ });
- test("Scrolls instructions to the top", () => {
- expect(window.HTMLElement.prototype.scrollTo).toHaveBeenCalledWith({
- top: 0,
+ test("Renders the edit panel", () => {
+ expect(screen.getByTestId("instructionTextarea")).toBeInTheDocument();
});
- });
- test("Renders the progress bar", () => {
- expect(screen.queryByRole("progressbar")).toBeInTheDocument();
- });
+ test("saves content", async () => {
+ const textarea = screen.getByTestId("instructionTextarea");
+ const testString = "SomeInstructions";
- test("Applies syntax highlighting to python code", () => {
- const codeElement = document.getElementsByClassName("language-python")[0];
- expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
- });
+ fireEvent.change(textarea, { target: { value: testString } });
- test("Applies syntax highlighting to HTML code", () => {
- const codeElement = document.getElementsByClassName("language-html")[0];
- expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
- });
+ await waitFor(() => {
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([setProjectInstructions(testString)]),
+ );
+ });
+ });
- test("Applies syntax highlighting to CSS code", () => {
- const codeElement = document.getElementsByClassName("language-css")[0];
- expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
+ test("Does not render the add instructions button", () => {
+ expect(
+ screen.queryByText("instructionsPanel.emptyState.addInstructions"),
+ ).not.toBeInTheDocument();
+ });
});
-});
-describe("When instructionsEditable is true", () => {
- let store;
-
- beforeEach(() => {
- const mockStore = configureStore([]);
- const initialState = {
- editor: {
- project: {},
- instructionsEditable: true,
- },
- instructions: {
- project: {
- steps: [{ content: "instructions" }],
- },
- quiz: {},
- currentStepPosition: 1,
- },
- };
- store = mockStore(initialState);
- render(
-
-
- ,
- );
- });
+ describe("When there are no instructions", () => {
+ let store;
- test("Renders the edit panel", () => {
- expect(screen.getByTestId("instructionTextarea")).toBeInTheDocument();
- });
+ beforeEach(() => {
+ const mockStore = configureStore([]);
+ const initialState = {
+ editor: {
+ project: {},
+ instructionsEditable: true,
+ },
+ instructions: {
+ project: {
+ steps: [],
+ },
+ quiz: {},
+ currentStepPosition: 1,
+ },
+ };
+ store = mockStore(initialState);
+ render(
+
+
+ ,
+ );
+ });
- test("saves content", async () => {
- const textarea = screen.getByTestId("instructionTextarea");
- const testString = "SomeInstructions";
+ test("Renders the add instrucitons button", () => {
+ expect(
+ screen.queryByText("instructionsPanel.emptyState.addInstructions"),
+ ).toBeInTheDocument();
+ });
- fireEvent.change(textarea, { target: { value: testString } });
+ test("Clicking the add instructions button adds the demo instructions", () => {
+ const addInstructionsButton = screen.getByText(
+ "instructionsPanel.emptyState.addInstructions",
+ );
+ act(() => {
+ fireEvent.click(addInstructionsButton);
+ });
- await waitFor(() => {
expect(store.getActions()).toEqual(
- expect.arrayContaining([setProjectInstructions(testString)]),
+ expect.arrayContaining([setProjectInstructions("demoInstructions.md")]),
);
});
+
+ test("Renders the instructions explanation", () => {
+ expect(
+ screen.queryByText("instructionsPanel.emptyState.purpose"),
+ ).toBeInTheDocument();
+ });
});
});
-describe("When there is only one step", () => {
- beforeEach(() => {
- const mockStore = configureStore([]);
- const initialState = {
- editor: {
- instructionsEditable: false,
- },
- instructions: {
- project: {
- steps: [{ content: "
step 0
" }],
+describe("When instructions are not editable", () => {
+ describe("When there are no instructions", () => {
+ beforeEach(() => {
+ const mockStore = configureStore([]);
+ const initialState = {
+ editor: {
+ project: {},
+ instructionsEditable: false,
},
- quiz: {},
- currentStepPosition: 0,
- },
- };
- const store = mockStore(initialState);
- render(
-
-
- ,
- );
- });
+ instructions: {
+ project: {
+ steps: [],
+ },
+ quiz: {},
+ currentStepPosition: 1,
+ },
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+ ,
+ );
+ });
+
+ test("Does not render the add instructions button", () => {
+ expect(
+ screen.queryByText("instructionsPanel.emptyState.addInstructions"),
+ ).not.toBeInTheDocument();
+ });
+
+ test("Does not render the instructions explanation", () => {
+ expect(
+ screen.queryByText("instructionsPanel.emptyState.purpose"),
+ ).not.toBeInTheDocument();
+ });
+
+ test("It renders without crashing", () => {
+ expect(
+ screen.queryByText("instructionsPanel.projectSteps"),
+ ).toBeInTheDocument();
+ });
- test("Does not render the progress bar", () => {
- expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ test("Does not render the progress bar", () => {
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
});
-});
-describe("When there are no steps", () => {
- beforeEach(() => {
- const mockStore = configureStore([]);
- const initialState = {
- editor: {
- instructionsEditable: false,
- },
- instructions: {
- project: {
- steps: [],
+ describe("When there are instructions", () => {
+ beforeEach(() => {
+ const mockStore = configureStore([]);
+ const initialState = {
+ editor: {
+ project: {},
+ instructionsEditable: false,
},
- quiz: {},
- currentStepPosition: 0,
- },
- };
- const store = mockStore(initialState);
- render(
-
-
- ,
- );
- });
+ instructions: {
+ project: {
+ steps: [
+ { content: "
step 0
" },
+ {
+ content: `
step 1
+
print('hello')
+
Hello world
+
.hello { color: purple }
+ `,
+ },
+ ],
+ },
+ quiz: {},
+ currentStepPosition: 1,
+ },
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+ ,
+ );
+ });
- test("It renders without crashing", () => {
- expect(
- screen.queryByText("instructionsPanel.projectSteps"),
- ).toBeInTheDocument();
- });
+ test("Renders with correct instruction step content", () => {
+ expect(screen.queryByText("step 1")).toBeInTheDocument();
+ });
- test("Does not render the progress bar", () => {
- expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
- });
-});
+ test("Scrolls instructions to the top", () => {
+ expect(window.HTMLElement.prototype.scrollTo).toHaveBeenCalledWith({
+ top: 0,
+ });
+ });
-describe("It renders a quiz when it has one", () => {
- const quizHandler = jest.fn();
+ test("Renders the progress bar", () => {
+ expect(screen.queryByRole("progressbar")).toBeInTheDocument();
+ });
+
+ test("Applies syntax highlighting to python code", () => {
+ const codeElement = document.getElementsByClassName("language-python")[0];
+ expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
+ });
- beforeAll(() => {
- document.addEventListener("editor-quizReady", quizHandler);
+ test("Applies syntax highlighting to HTML code", () => {
+ const codeElement = document.getElementsByClassName("language-html")[0];
+ expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
+ });
+
+ test("Applies syntax highlighting to CSS code", () => {
+ const codeElement = document.getElementsByClassName("language-css")[0];
+ expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
+ });
});
- beforeEach(() => {
- const mockStore = configureStore([]);
- const initialState = {
- instructions: {
- project: {
- steps: [
- { content: "
step 0
" },
- { content: "
step 1
", knowledgeQuiz: "quizPath" },
- ],
+
+ describe("When there is only one step", () => {
+ beforeEach(() => {
+ const mockStore = configureStore([]);
+ const initialState = {
+ editor: {
+ instructionsEditable: false,
},
- quiz: {
- questions: [
- "
Test quiz
step 1
print('hello')
",
- ],
- questionCount: 1,
- currentQuestion: 0,
+ instructions: {
+ project: {
+ steps: [{ content: "
step 0
" }],
+ },
+ quiz: {},
+ currentStepPosition: 0,
},
- currentStepPosition: 1,
- },
- };
- const store = mockStore(initialState);
- render(
-
-
- ,
- );
- });
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+ ,
+ );
+ });
- test("Renders the quiz content", () => {
- expect(screen.queryByText("Test quiz")).toBeInTheDocument();
+ test("Does not render the progress bar", () => {
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
});
- test("Scrolls instructions to the top", () => {
- expect(window.HTMLElement.prototype.scrollTo).toHaveBeenCalledWith({
- top: 0,
+ describe("When there is a quiz", () => {
+ const quizHandler = jest.fn();
+
+ beforeAll(() => {
+ document.addEventListener("editor-quizReady", quizHandler);
+ });
+ beforeEach(() => {
+ const mockStore = configureStore([]);
+ const initialState = {
+ instructions: {
+ project: {
+ steps: [
+ { content: "
step 0
" },
+ { content: "
step 1
", knowledgeQuiz: "quizPath" },
+ ],
+ },
+ quiz: {
+ questions: [
+ "
Test quiz
step 1
print('hello')
",
+ ],
+ questionCount: 1,
+ currentQuestion: 0,
+ },
+ currentStepPosition: 1,
+ },
+ };
+ const store = mockStore(initialState);
+ render(
+
+
+ ,
+ );
});
- });
- test("Retains the progress bar", () => {
- expect(screen.queryByRole("progressbar")).toBeInTheDocument();
- });
+ test("Renders the quiz content", () => {
+ expect(screen.queryByText("Test quiz")).toBeInTheDocument();
+ });
- test("Applies syntax highlighting", () => {
- const codeElement = document.getElementsByClassName("language-python")[0];
- expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
- });
+ test("Scrolls instructions to the top", () => {
+ expect(window.HTMLElement.prototype.scrollTo).toHaveBeenCalledWith({
+ top: 0,
+ });
+ });
- test("Fires a quizIsReady event", () => {
- expect(quizHandler).toHaveBeenCalled();
+ test("Retains the progress bar", () => {
+ expect(screen.queryByRole("progressbar")).toBeInTheDocument();
+ });
+
+ test("Applies syntax highlighting", () => {
+ const codeElement = document.getElementsByClassName("language-python")[0];
+ expect(window.Prism.highlightElement).toHaveBeenCalledWith(codeElement);
+ });
+
+ test("Fires a quizIsReady event", () => {
+ expect(quizHandler).toHaveBeenCalled();
+ });
});
});
diff --git a/src/utils/i18n.js b/src/utils/i18n.js
index eeab05286..8b538c8b4 100644
--- a/src/utils/i18n.js
+++ b/src/utils/i18n.js
@@ -206,6 +206,16 @@ i18n
info: "Information",
},
instructionsPanel: {
+ emptyState: {
+ addInstructions: "Add instructions",
+ edits:
+ "Like project code, students will not see any edits you make to the instructions after they have saved their version of the project.",
+ location:
+ "These instructions will be shown to students in their project sidebar and will be view-only.",
+ markdown: "Instructions are written in <0>markdown0>.",
+ purpose:
+ "Instructions can be added to your project to guide students.",
+ },
nextStep: "Next step",
previousStep: "Previous step",
projectSteps: "Project steps",
diff --git a/src/utils/setupTests.js b/src/utils/setupTests.js
index d0025dcd8..485976317 100644
--- a/src/utils/setupTests.js
+++ b/src/utils/setupTests.js
@@ -42,6 +42,10 @@ jest.mock("./i18n", () => ({
t: (string) => string,
}));
+jest.mock("../assets/markdown/demoInstructions.md", () => {
+ return "demoInstructions.md";
+});
+
global.Blob = jest.fn();
window.URL.createObjectURL = jest.fn();
window.Worker = PyodideWorker;
diff --git a/webpack.config.js b/webpack.config.js
index 0666b78f8..536abd2a4 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -43,6 +43,10 @@ module.exports = {
},
],
},
+ {
+ test: /\.md$/,
+ use: ["raw-loader"],
+ },
{
test: /\/src\/assets\/icons\/.*\.svg$/,
use: [
diff --git a/yarn.lock b/yarn.lock
index c8beaed28..79885cf79 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2836,6 +2836,7 @@ __metadata:
prismjs: ^1.29.0
prompts: 2.4.0
prop-types: ^15.8.1
+ raw-loader: ^4.0.2
rc-resize-observer: ^1.3.1
re-resizable: 6.9.9
react: ^18.1.0
@@ -15048,6 +15049,18 @@ __metadata:
languageName: node
linkType: hard
+"raw-loader@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "raw-loader@npm:4.0.2"
+ dependencies:
+ loader-utils: ^2.0.0
+ schema-utils: ^3.0.0
+ peerDependencies:
+ webpack: ^4.0.0 || ^5.0.0
+ checksum: 51cc1b0d0e8c37c4336b5318f3b2c9c51d6998ad6f56ea09612afcfefc9c1f596341309e934a744ae907177f28efc9f1654eacd62151e82853fcc6d37450e795
+ languageName: node
+ linkType: hard
+
"rc-resize-observer@npm:^1.3.1":
version: 1.4.0
resolution: "rc-resize-observer@npm:1.4.0"
From 6bacafd653ac8e95d8cbd91334433788ad7e165a Mon Sep 17 00:00:00 2001
From: Lois Wells <88904316+loiswells97@users.noreply.github.com>
Date: Fri, 17 Jan 2025 12:17:11 +0000
Subject: [PATCH 5/6] Fix markdown link (#1168)
---
.github/workflows/ci-cd.yml | 2 +-
CHANGELOG.md | 2 +-
.../InstructionsPanel/InstructionsPanel.jsx | 22 +++++++++----------
src/utils/setupTests.js | 1 +
4 files changed, 13 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index bb741e939..66d19cbdc 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -104,7 +104,7 @@ jobs:
REACT_APP_PLAUSIBLE_SOURCE: ""
- name: Archive cypress artifacts
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4.6.0
if: failure()
with:
name: cypress-artifacts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14f7cfb59..e2345af35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Downloading project instructions (#1160)
- Show instructions option in sidebar if instructions are editable (#1164)
- Open instructions panel by default if instructions are editable (#1164)
-- Instructions empty state to show when instructions are editable (#1165)
+- Instructions empty state to show when instructions are editable (#1165, ##1168)
### Changed
diff --git a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx
index cb0048840..b3574e308 100644
--- a/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx
+++ b/src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.jsx
@@ -146,18 +146,16 @@ const InstructionsPanel = () => {
{t("instructionsPanel.emptyState.location")}
- {() => (
- ,
- ]}
- />
- )}
+ ,
+ ]}
+ />
{t("instructionsPanel.emptyState.edits")}
diff --git a/src/utils/setupTests.js b/src/utils/setupTests.js
index 485976317..201443727 100644
--- a/src/utils/setupTests.js
+++ b/src/utils/setupTests.js
@@ -36,6 +36,7 @@ jest.mock("react-i18next", () => ({
},
};
},
+ Trans: ({ children, i18nKey }) => children || i18nKey,
}));
jest.mock("./i18n", () => ({
From 5a2cdf2933f8be7ee4f3e934db57bd50363bb575 Mon Sep 17 00:00:00 2001
From: Lois Wells <88904316+loiswells97@users.noreply.github.com>
Date: Thu, 23 Jan 2025 11:51:54 +0000
Subject: [PATCH 6/6] Fix cached instructions (#1169)
closes
https://github.com/RaspberryPiFoundation/digital-editor-issues/issues/392
---
CHANGELOG.md | 2 +
.../WebComponentProject.jsx | 8 +-
.../WebComponentProject.test.js | 291 ++++++++----------
src/containers/WebComponentLoader.jsx | 2 +-
src/containers/WebComponentLoader.test.js | 6 +-
src/redux/EditorSlice.js | 6 +-
src/redux/InstructionsSlice.js | 11 +-
src/redux/RootSlice.js | 33 ++
src/redux/RootSlice.test.js | 57 ++++
src/redux/WebComponentAuthSlice.js | 4 +-
src/redux/stores/WebComponentStore.js | 11 +-
src/web-component.js | 2 +
12 files changed, 243 insertions(+), 190 deletions(-)
create mode 100644 src/redux/RootSlice.js
create mode 100644 src/redux/RootSlice.test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e962fc89..d670c2a63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,10 +16,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Show instructions option in sidebar if instructions are editable (#1164)
- Open instructions panel by default if instructions are editable (#1164)
- Instructions empty state to show when instructions are editable (#1165, ##1168)
+- Allow `instructions` attribute to override instructions attached to the project (#1169)
### Changed
- Made `INSTRUCTIONS.md` a reserved file name (#1160)
+- Clear the redux store when the component unmounts (#1169)
## [0.28.14] - 2025-01-06
diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx
index 83c4fe8f6..92156014f 100644
--- a/src/components/WebComponentProject/WebComponentProject.jsx
+++ b/src/components/WebComponentProject/WebComponentProject.jsx
@@ -54,6 +54,9 @@ const WebComponentProject = ({
const currentStepPosition = useSelector(
(state) => state.instructions.currentStepPosition,
);
+ const permitInstructionsOverride = useSelector(
+ (state) => state.instructions.permitOverride,
+ );
const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY });
const [codeHasRun, setCodeHasRun] = useState(codeHasBeenRun);
const dispatch = useDispatch();
@@ -80,7 +83,7 @@ const WebComponentProject = ({
}, [projectIdentifier]);
useEffect(() => {
- if (!projectInstructions) return;
+ if (!projectInstructions || !permitInstructionsOverride) return;
dispatch(
setInstructions({
@@ -93,9 +96,10 @@ const WebComponentProject = ({
},
],
},
+ permitOverride: true,
}),
);
- }, [dispatch, projectInstructions]);
+ }, [dispatch, projectInstructions, permitInstructionsOverride]);
useEffect(() => {
if (codeRunTriggered) {
diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js
index 4a303121b..2141c095d 100644
--- a/src/components/WebComponentProject/WebComponentProject.test.js
+++ b/src/components/WebComponentProject/WebComponentProject.test.js
@@ -20,34 +20,52 @@ jest.useFakeTimers();
let store;
+const renderWebComponentProject = ({
+ instructions,
+ permitOverride = true,
+ loading,
+ codeRunTriggered = false,
+ codeHasBeenRun = false,
+ props = {},
+}) => {
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {
+ components: [
+ { name: "main", extension: "py", content: "print('hello')" },
+ ],
+ image_list: [],
+ instructions,
+ },
+ loading,
+ openFiles: [],
+ focussedFileIndices: [],
+ codeRunTriggered,
+ codeHasBeenRun,
+ },
+ instructions: {
+ currentStepPosition: 3,
+ permitOverride,
+ },
+ auth: {},
+ };
+ store = mockStore(initialState);
+
+ render(
+
+
+ ,
+ );
+};
+
describe("When state set", () => {
beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- { name: "main", extension: "py", content: "print('hello')" },
- ],
- image_list: [],
- },
- openFiles: [],
- focussedFileIndices: [],
- codeRunTriggered: true,
- },
- instructions: {
- currentStepPosition: 3,
- },
- auth: {},
- };
- store = mockStore(initialState);
-
- render(
-
-
- ,
- );
+ renderWebComponentProject({
+ instructions: "My amazing instructions",
+ codeRunTriggered: true,
+ });
});
test("Triggers codeChanged event", () => {
@@ -72,40 +90,70 @@ describe("When state set", () => {
test("Defaults to not showing the projectbar", () => {
expect(screen.queryByText("header.newProject")).not.toBeInTheDocument();
});
+
+ test("Dispatches action to set instructions", () => {
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([
+ {
+ type: "instructions/setInstructions",
+ payload: {
+ permitOverride: true,
+ project: {
+ steps: [
+ {
+ title: "",
+ content: "
My amazing instructions
\n",
+ quiz: false,
+ },
+ ],
+ },
+ },
+ },
+ ]),
+ );
+ });
});
-describe("When code run finishes", () => {
- let store;
+describe("When there are no instructions", () => {
+ beforeEach(() => {
+ renderWebComponentProject({});
+ });
+ test("Does not dispatch action to set instructions", () => {
+ expect(store.getActions()).not.toEqual(
+ expect.arrayContaining([
+ {
+ type: "instructions/setInstructions",
+ payload: expect.any(Object),
+ },
+ ]),
+ );
+ });
+});
+
+describe("When overriding instructions is not permitted", () => {
beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- { name: "main", extension: "py", content: "print('hello')" },
- ],
- image_list: [],
+ renderWebComponentProject({
+ instructions: "My amazing instructions",
+ permitOverride: false,
+ });
+ });
+
+ test("Does not dispatch action to set instructions", () => {
+ expect(store.getActions()).not.toEqual(
+ expect.arrayContaining([
+ {
+ type: "instructions/setInstructions",
+ payload: expect.any(Object),
},
- openFiles: [],
- focussedFileIndices: [],
- codeRunTriggered: false,
- codeHasBeenRun: true,
- },
- instructions: {},
- auth: {},
- };
- store = mockStore(initialState);
-
- render(
-
-
- ,
+ ]),
);
});
+});
+describe("When code run finishes", () => {
test("Triggers runCompletedEvent", () => {
+ renderWebComponentProject({ codeHasBeenRun: true });
expect(runCompletedHandler).toHaveBeenCalled();
expect(runCompletedHandler.mock.lastCall[0].detail).toHaveProperty(
"isErrorFree",
@@ -113,11 +161,10 @@ describe("When code run finishes", () => {
});
test("Triggers runCompletedEvent with error details when outputOnly is true", () => {
- render(
-
-
- ,
- );
+ renderWebComponentProject({
+ codeHasBeenRun: true,
+ props: { outputOnly: true },
+ });
expect(runCompletedHandler).toHaveBeenCalled();
expect(runCompletedHandler.mock.lastCall[0].detail).toHaveProperty(
"errorDetails",
@@ -127,29 +174,9 @@ describe("When code run finishes", () => {
describe("When withSidebar is true", () => {
beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- { name: "main", extension: "py", content: "print('hello')" },
- ],
- image_list: [],
- },
- openFiles: [],
- focussedFileIndices: [],
- },
- instructions: {},
- auth: {},
- };
- store = mockStore(initialState);
-
- render(
-
-
- ,
- );
+ renderWebComponentProject({
+ props: { withSidebar: true, sidebarOptions: ["settings"] },
+ });
});
test("Renders the sidebar", () => {
@@ -163,30 +190,10 @@ describe("When withSidebar is true", () => {
describe("When withProjectbar is true", () => {
beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- { name: "main", extension: "py", content: "print('hello')" },
- ],
- image_list: [],
- },
- openFiles: [],
- focussedFileIndices: [],
- loading: "success",
- },
- instructions: {},
- auth: {},
- };
- store = mockStore(initialState);
-
- render(
-
-
- ,
- );
+ renderWebComponentProject({
+ loading: "success",
+ props: { withProjectbar: true },
+ });
});
test("Renders the projectbar", () => {
@@ -195,35 +202,12 @@ describe("When withProjectbar is true", () => {
});
describe("When output_only is true", () => {
- let mockStore;
- let initialState;
-
- beforeEach(() => {
- const middlewares = [];
- mockStore = configureStore(middlewares);
- initialState = {
- editor: {
- project: {
- components: [],
- },
- openFiles: [],
- focussedFileIndices: [],
- },
- instructions: {},
- auth: {},
- };
- });
-
describe("when loading is pending", () => {
beforeEach(() => {
- initialState.editor.loading = "pending";
- store = mockStore(initialState);
-
- render(
-
-
- ,
- );
+ renderWebComponentProject({
+ loading: "pending",
+ props: { outputOnly: true },
+ });
});
test("sets isOutputOnly state to true", () => {
@@ -241,14 +225,10 @@ describe("When output_only is true", () => {
describe("when loading is success", () => {
beforeEach(() => {
- initialState.editor.loading = "success";
- store = mockStore(initialState);
-
- render(
-
-
- ,
- );
+ renderWebComponentProject({
+ loading: "success",
+ props: { outputOnly: true },
+ });
});
test("only renders the output", () => {
@@ -264,30 +244,9 @@ describe("When output_only is true", () => {
});
describe("outputSplitView property", () => {
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [],
- },
- openFiles: [],
- focussedFileIndices: [],
- },
- instructions: {},
- auth: {},
- };
- store = mockStore(initialState);
- });
-
describe("when property is not set", () => {
beforeEach(() => {
- render(
-
-
- ,
- );
+ renderWebComponentProject({});
});
test("sets isSplitView state to false by default", () => {
@@ -301,11 +260,7 @@ describe("outputSplitView property", () => {
describe("when property is false", () => {
beforeEach(() => {
- render(
-
-
- ,
- );
+ renderWebComponentProject({ props: { outputSplitView: false } });
});
test("sets isSplitView state to false", () => {
@@ -319,11 +274,7 @@ describe("outputSplitView property", () => {
describe("when property is true", () => {
beforeEach(() => {
- render(
-
-
- ,
- );
+ renderWebComponentProject({ props: { outputSplitView: true } });
});
test("sets isSplitView state to true", () => {
diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx
index 10f0f78bf..353b5ac41 100644
--- a/src/containers/WebComponentLoader.jsx
+++ b/src/containers/WebComponentLoader.jsx
@@ -153,7 +153,7 @@ const WebComponentLoader = (props) => {
useEffect(() => {
if (instructions) {
- dispatch(setInstructions(instructions));
+ dispatch(setInstructions({ ...instructions, permitOverride: false }));
}
}, [instructions, dispatch]);
diff --git a/src/containers/WebComponentLoader.test.js b/src/containers/WebComponentLoader.test.js
index 34c5bac4f..b82bcd9f5 100644
--- a/src/containers/WebComponentLoader.test.js
+++ b/src/containers/WebComponentLoader.test.js
@@ -29,7 +29,11 @@ let cookies;
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 instructions = {
+ currentStepPosition: 3,
+ project: { steps: steps },
+ permitOverride: false,
+};
const authKey = "my_key";
const user = { access_token: "my_token" };
diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js
index 24c5e462f..be193b1f1 100644
--- a/src/redux/EditorSlice.js
+++ b/src/redux/EditorSlice.js
@@ -93,7 +93,7 @@ export const loadProjectList = createAsyncThunk(
},
);
-const initialState = {
+export const editorInitialState = {
project: {},
cascadeUpdate: false,
readOnly: false,
@@ -144,9 +144,9 @@ const initialState = {
export const EditorSlice = createSlice({
name: "editor",
- initialState,
+ initialState: editorInitialState,
reducers: {
- resetState: () => initialState,
+ resetState: () => editorInitialState,
closeFile: (state, action) => {
const panelIndex = state.openFiles
.map((fileNames) => fileNames.includes(action.payload))
diff --git a/src/redux/InstructionsSlice.js b/src/redux/InstructionsSlice.js
index d9c639db9..d3ac28165 100644
--- a/src/redux/InstructionsSlice.js
+++ b/src/redux/InstructionsSlice.js
@@ -1,12 +1,15 @@
import { createSlice } from "@reduxjs/toolkit";
import { reducers } from "./reducers/instructionsReducers";
+export const instructionsInitialState = {
+ currentStepPosition: 0,
+ project: {},
+ permitOverride: true,
+};
+
export const InstructionsSlice = createSlice({
name: "instructions",
- initialState: {
- currentStepPosition: 0,
- project: {},
- },
+ initialState: instructionsInitialState,
reducers,
});
diff --git a/src/redux/RootSlice.js b/src/redux/RootSlice.js
new file mode 100644
index 000000000..3ef2e6ed3
--- /dev/null
+++ b/src/redux/RootSlice.js
@@ -0,0 +1,33 @@
+import { createSlice } from "@reduxjs/toolkit";
+import editorReducer, { editorInitialState } from "./EditorSlice.js";
+import instructionsReducer, {
+ instructionsInitialState,
+} from "./InstructionsSlice.js";
+import authReducer, { authInitialState } from "./WebComponentAuthSlice.js";
+
+const initialState = {
+ editor: editorInitialState,
+ instructions: instructionsInitialState,
+ auth: authInitialState,
+};
+
+const RootSlice = createSlice({
+ name: "root",
+ initialState,
+ reducers: {
+ resetStore: (state) => {
+ state.editor = editorInitialState;
+ state.instructions = instructionsInitialState;
+ },
+ },
+ extraReducers: (builder) => {
+ builder.addDefaultCase((state, action) => {
+ state.editor = editorReducer(state.editor, action);
+ state.instructions = instructionsReducer(state.instructions, action);
+ state.auth = authReducer(state.auth, action);
+ });
+ },
+});
+
+export const { resetStore } = RootSlice.actions;
+export default RootSlice.reducer;
diff --git a/src/redux/RootSlice.test.js b/src/redux/RootSlice.test.js
new file mode 100644
index 000000000..acab31650
--- /dev/null
+++ b/src/redux/RootSlice.test.js
@@ -0,0 +1,57 @@
+import reducer, { resetStore } from "./RootSlice";
+import { editorInitialState } from "./EditorSlice";
+import { instructionsInitialState } from "./InstructionsSlice";
+
+test("Action reset store clears the project data", () => {
+ const previousState = {
+ editor: {
+ ...editorInitialState,
+ project: {
+ components: [
+ { name: "main", extension: "py", content: "print('hello world')" },
+ ],
+ },
+ },
+ instructions: instructionsInitialState,
+ };
+
+ expect(reducer(previousState, resetStore())).toEqual({
+ editor: editorInitialState,
+ instructions: instructionsInitialState,
+ });
+});
+
+test("Action reset store clears the instructions data", () => {
+ const previousState = {
+ editor: editorInitialState,
+ instructions: {
+ ...instructionsInitialState,
+ project: {
+ steps: [{ content: "
step 0
" }],
+ },
+ },
+ };
+
+ expect(reducer(previousState, resetStore())).toEqual({
+ editor: editorInitialState,
+ instructions: instructionsInitialState,
+ });
+});
+
+test("Action reset store does not clear the auth data", () => {
+ const previousState = {
+ editor: editorInitialState,
+ instructions: instructionsInitialState,
+ auth: {
+ user: { access_token: "1234" },
+ },
+ };
+
+ expect(reducer(previousState, resetStore())).toEqual({
+ editor: editorInitialState,
+ instructions: instructionsInitialState,
+ auth: {
+ user: { access_token: "1234" },
+ },
+ });
+});
diff --git a/src/redux/WebComponentAuthSlice.js b/src/redux/WebComponentAuthSlice.js
index 43579db30..cfc77788a 100644
--- a/src/redux/WebComponentAuthSlice.js
+++ b/src/redux/WebComponentAuthSlice.js
@@ -1,9 +1,11 @@
import { createSlice } from "@reduxjs/toolkit";
import { reducers } from "./reducers/webComponentAuthReducers";
+export const authInitialState = {};
+
export const WebComponentAuthSlice = createSlice({
name: "auth",
- initialState: {},
+ initialState: authInitialState,
reducers,
});
diff --git a/src/redux/stores/WebComponentStore.js b/src/redux/stores/WebComponentStore.js
index 4de6eb83f..a2f1e3aa1 100644
--- a/src/redux/stores/WebComponentStore.js
+++ b/src/redux/stores/WebComponentStore.js
@@ -1,15 +1,10 @@
import { configureStore } from "@reduxjs/toolkit";
-import EditorReducer from "../EditorSlice";
-import InstructionsReducer from "../InstructionsSlice";
-import WebComponentAuthReducer, { setUser } from "../WebComponentAuthSlice";
+import { setUser } from "../WebComponentAuthSlice";
+import rootReducer from "../RootSlice";
import userMiddleWare from "../middlewares/localStorageUserMiddleware";
const store = configureStore({
- reducer: {
- editor: EditorReducer,
- instructions: InstructionsReducer,
- auth: WebComponentAuthReducer,
- },
+ reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
diff --git a/src/web-component.js b/src/web-component.js
index 68276729a..cb65ea397 100644
--- a/src/web-component.js
+++ b/src/web-component.js
@@ -9,6 +9,7 @@ import "./utils/i18n";
import camelCase from "camelcase";
import { stopCodeRun, stopDraw, triggerCodeRun } from "./redux/EditorSlice";
import { BrowserRouter } from "react-router-dom";
+import { resetStore } from "./redux/RootSlice";
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
@@ -42,6 +43,7 @@ class WebComponent extends HTMLElement {
console.log("Unmounted web-component...");
this.root.unmount();
}
+ store.dispatch(resetStore());
}
static get observedAttributes() {