diff --git a/README.md b/README.md index 3a87e43..da3ee1e 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,11 @@ _Learn, repeat and memorize tasks with repeatio._ ## [Start Learning](https://repeatio.netlify.app) -## :construction: Current limitations (v0.3) :construction: +## :construction: Current limitations (v0.4) :construction: > **Warning** > Not all browser and devices are supported! > Currently not all UI elements are working (e.g. module progress)! -> Editing and deleting questions only works when using the default practice mode (not random/saved)! > Only use Electron if you know how to edit .json files. The website should work just fine for most users. ## Browser Support @@ -98,9 +97,6 @@ The order of the questions is random but each question will only be shown once. -> **Warning** -> Don't delete or edit questions when using this mode! - ### Bookmarked To practice with the questions you bookmarked navigate to the module and click the `Start >` button inside `Bookmarked Questions`. @@ -108,9 +104,6 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que Train Bookmarked Questions -> **Warning** -> Don't delete or edit questions when using this mode! - ## Adding and editing Questions ### Add a new Question @@ -130,9 +123,6 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que > **Note** > On mobile you may have to first extend the bottom of the navigation -> **Warning** -> Editing questions while using the mode random or saved Question is currently not supported! - ### Question Editor #### Fields @@ -193,7 +183,7 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que } ``` - **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-1?mode=chronological)_** + **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-1?mode=practice&order=chronological)_** @@ -251,7 +241,7 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que } ``` - **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-2?mode=chronological)_** + **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-2?mode=practice&order=chronological)_** @@ -292,7 +282,7 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que } ``` - **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-3?mode=chronological)_** + **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-3?mode=practice&order=chronological)_** @@ -343,7 +333,7 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que } ``` - **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-4?mode=chronological)_** + **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-4?mode=practice&order=chronological)_** @@ -406,7 +396,7 @@ The questions are in the order that they were saved in. **[Read](#bookmarked-que } ``` - **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-5?mode=chronological)_** + **_[Result](https://repeatio.netlify.app/module/types_1/question/qID-5?mode=practice&order=chronological)_** diff --git a/cypress/e2e/QuestionOverview.cy.ts b/cypress/e2e/QuestionOverview.cy.ts index d5cc8f5..eaf13a7 100644 --- a/cypress/e2e/QuestionOverview.cy.ts +++ b/cypress/e2e/QuestionOverview.cy.ts @@ -23,18 +23,21 @@ describe("Show questions of a module", () => { it("should navigate to the question if clicking on an arrow inside an item", () => { cy.visit("/module/cypress_1/all-questions"); - cy.get("#question-qID-2").find("button.button-to-question").click(); + cy.get("#question-qID-2").find("a.link-to-question").click(); cy.contains("Multiple Response questions have at least one correct answer.").should("exist"); + cy.url().should("include", "/module/cypress_1/question/qID-2?mode=practice&order=chronological"); }); }); context("Types bookmarked fixture", () => { - it("should reset filteredQuestions when clicking arrow", () => { + it("should reset question ids in the context when clicking arrow", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + // Add bookmarked questions - cy.fixtureToLocalStorage("repeatio-marked-types_1.json"); + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); // navigate to module - cy.visit("/module/types_1"); + cy.visit("/module/cypress_1"); // Visit bookmarked questions cy.get("article[data-cy='Bookmarked Questions']").contains("Start").click(); @@ -46,7 +49,7 @@ describe("Show questions of a module", () => { cy.get("a[aria-label='View all Questions']").click(); // Visit a question that is not in the bookmarked questions array - cy.get("#question-qID-5").find("button.button-to-question").click(); + cy.get("#question-qID-5").find("a.link-to-question").click(); // Check if question is shown cy.contains("This is a question of the type Extended Match.").should("exist"); diff --git a/cypress/e2e/addQuestions.cy.ts b/cypress/e2e/addQuestions.cy.ts index 47ed036..79538a8 100644 --- a/cypress/e2e/addQuestions.cy.ts +++ b/cypress/e2e/addQuestions.cy.ts @@ -247,7 +247,7 @@ describe("Adding a question of type gap-text", () => { cy.get("textarea#editor-gap-text-textarea").type("This is a simple [test]"); cy.get("button[type='submit']").click(); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.get("section.question-user-response").find("input").type("test"); cy.get("button[type='submit']").click(); cy.contains("Yes, that's correct!").should("exist"); @@ -257,7 +257,7 @@ describe("Adding a question of type gap-text", () => { cy.get("textarea#editor-gap-text-textarea").type("[This] is a [complex] [test]", { force: true }); cy.get("button[type='submit']").click(); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.get("section.question-user-response").find("input").first().type("This"); cy.get("section.question-user-response").find("input").eq(1).type("complex"); cy.get("section.question-user-response").find("input").last().type("test"); @@ -269,7 +269,7 @@ describe("Adding a question of type gap-text", () => { cy.get("textarea#editor-gap-text-textarea").type("This text supports [multiple; more than one] correct values"); cy.get("button[type='submit']").click(); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.get("section.question-user-response").find("input").type("more than one"); cy.get("button[type='submit']").click(); cy.contains("Yes, that's correct!").should("exist"); @@ -301,7 +301,7 @@ describe("Adding a question of type gap-text", () => { cy.get("textarea#editor-gap-text-textarea").type("This text is [split] into{enter}two[lines]"); cy.get("button[type='submit']").click(); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.get("div.question-gap-text").invoke("height").should("be.greaterThan", 45); cy.get("section.question-user-response").find("input").should("have.length", 2); }); @@ -313,7 +313,7 @@ describe("Adding a question of type gap-text", () => { ); cy.get("button[type='submit']").click(); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.get("table").should("exist").and("be.visible"); }); @@ -335,7 +335,7 @@ describe("Adding a question of type gap-text", () => { expect((addedQuestion?.answerOptions as IGapText).correctGapValues).to.deep.eq([["This"], ["gap", "hole"]]); }); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.contains("a", "link").should("exist"); cy.contains("a", "another link").should("exist"); cy.get("section.question-user-response").find("input").first().type("This"); @@ -369,7 +369,7 @@ describe("Adding a question of type gap-text", () => { expect((addedQuestion?.answerOptions as IGapText).correctGapValues).to.deep.eq([["gap", "hole"]]); }); - cy.visit("/module/empty-questions/question/test-id"); + cy.visit("/module/empty-questions/question/test-id?mode=practice&order=chronological"); cy.get("img").invoke("height").should("equal", 256); cy.get("section.question-user-response").find("input").should("have.length", 1); }); diff --git a/cypress/e2e/bookmarkedQuestions.cy.ts b/cypress/e2e/bookmarkedQuestions.cy.ts index 4f05643..c6f87d8 100644 --- a/cypress/e2e/bookmarkedQuestions.cy.ts +++ b/cypress/e2e/bookmarkedQuestions.cy.ts @@ -5,17 +5,27 @@ import { getBookmarkedQuestionsFromModule, IBookmarkedQuestions, } from "../../src/components/Question/components/Actions/BookmarkQuestion"; +import { parseJSON } from "../../src/utils/parseJSON"; /* ------------------------------------Bookmark in Module Overview ------------------------------ */ describe("Test usage of bookmarked Questions in module overview", () => { //Add fixture to localStorage and navigate to module url beforeEach(() => { - cy.visit("/module/types_1"); + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1"); + }); + + it("should changed the url to mode bookmarked on navigation", () => { + // Add bookmarked items to localStorage + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); + + cy.get("article[data-cy='Bookmarked Questions']").contains("button", "Start").click(); + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=bookmarked&order=chronological"); }); it("should display questions that are marked in the localStorage", () => { //Setup localStorage - cy.fixtureToLocalStorage("repeatio-marked-types_1.json"); + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); //Click on start cy.get("article[data-cy='Bookmarked Questions']").contains("button", "Start").click(); @@ -33,6 +43,54 @@ describe("Test usage of bookmarked Questions in module overview", () => { cy.get("article[data-cy='Bookmarked Questions']").contains("button", "Start").click(); cy.contains("Found 0 bookmarked questions for this module!"); }); + + it("should support removing and editing of question id while viewing bookmarked questions", () => { + /* Szenario: + 1. View bookmarked questions + 2. Remove question from bookmarked items + 3. Edit id of this question + 4. Navigate for and back */ + // Add bookmarked items to localStorage + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); + + cy.get("article[data-cy='Bookmarked Questions']").contains("button", "Start").click(); + + // Unsave the question + cy.get("button[aria-label='Unsave Question'").click(); + + // Edit the question id + cy.get("button[aria-label='Edit Question'").click(); + cy.get("input[name='id']").type("0"); + cy.contains("button", "Update").click(); + + // Assert that the question id changed + cy.contains("ID: qID-10").should("exist"); + + // Navigate to next question and back + cy.get("button[aria-label='Navigate to next Question']").click(); + cy.contains("ID: qID-3").should("exist"); + cy.get("button[aria-label='Navigate to previous Question']").click(); + cy.contains("ID: qID-10").should("exist"); + }); + + it("should start bookmarked practice with question that exists and show warning in console", () => { + const bookmarkedFile = { + id: "cypress_1", + type: "marked", + compatibility: "0.4.0", + questions: ["invalid-id", "qID-1", "also-invalid"], + }; + + // Update the localStorage with the new item + localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(bookmarkedFile, null, "\t")); + + // Start practicing with bookmarked questions + cy.get("article[data-cy='Bookmarked Questions']").contains("button", "Start").click(); + + cy.url().should("include", "module/cypress_1/question/qID-1?mode=bookmarked&order=chronological"); + + // Get url + }); }); /* --------------------------------------------- EXPORT ----------------------------------------- */ @@ -47,7 +105,8 @@ describe("Test export of bookmarked Questions", () => { //navigate to module url beforeEach(() => { - cy.visit("/module/types_1", { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1", { onBeforeLoad(win) { cy.spy(win.console, "log").as("consoleLog"); cy.stub(win.console, "error").as("consoleError"); @@ -59,16 +118,16 @@ describe("Test export of bookmarked Questions", () => { it("should download marked questions from localStorage", () => { //Add fixture to localStorage - cy.fixtureToLocalStorage("repeatio-marked-types_1.json"); + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); cy.contains("li", "Export").click(); - const downloadedFilename = path.join(downloadsFolder, "repeatio-marked-types_1.json"); + const downloadedFilename = path.join(downloadsFolder, "repeatio-marked-cypress_1.json"); //Compare the downloaded file with the original file in the localStorage (which comes from a fixture) cy.readFile(downloadedFilename).then((downloadedContent) => { - cy.fixture("repeatio-marked-types_1.json").then((fixtureContent) => { + cy.fixture("repeatio-marked-cypress_1.json").then((fixtureContent) => { expect(downloadedContent).to.deep.equal(fixtureContent); }); }); @@ -80,12 +139,12 @@ describe("Test export of bookmarked Questions", () => { //Test toast and console.error cy.get(".Toastify").contains( - `Failed to export the bookmarked questions for "types_1" because there aren't any bookmarked questions!` + `Failed to export the bookmarked questions for "cypress_1" because there aren't any bookmarked questions!` ); cy.get("@consoleError").should( "be.calledWithMatch", - /\[.*\] Failed to export the bookmarked questions for "types_1" because there aren't any bookmarked questions\!/ + /\[.*\] Failed to export the bookmarked questions for "cypress_1" because there aren't any bookmarked questions\!/ ); }); }); @@ -95,23 +154,24 @@ describe("Test export of bookmarked Questions", () => { describe("Test import of bookmarked Questions", () => { //Add fixture to localStorage and navigate to module url beforeEach(() => { - cy.fixtureToLocalStorage("repeatio-marked-types_1.json"); - cy.visit("/module/types_1"); + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1"); }); it("should import bookmarked Questions", () => { //Remove localStorage that was added by beforeEach hook - cy.clearLocalStorage("repeatio-marked-types_1"); + cy.clearLocalStorage("repeatio-marked-cypress_1"); //Find popover button (3 dots) cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); - cy.fixture("repeatio-marked-types_1.json").then((fileContent) => { + cy.fixture("repeatio-marked-cypress_1.json").then((fileContent) => { cy.get("input[type=file]") - .selectFile({ contents: fileContent, fileName: "repeatio-marked-types_1.json" }, { force: true }) + .selectFile({ contents: fileContent, fileName: "repeatio-marked-cypress_1.json" }, { force: true }) .should(() => { - const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("types_1"); - expect(bookmarkedLocalStorageItem?.id).to.equal("types_1"); + const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("cypress_1"); + expect(bookmarkedLocalStorageItem?.id).to.equal("cypress_1"); expect(bookmarkedLocalStorageItem?.type).to.equal("marked"); expect(bookmarkedLocalStorageItem?.questions).to.deep.equal(["qID-1", "qID-3"]); }); @@ -126,13 +186,13 @@ describe("Test import of bookmarked Questions", () => { //Build new file //Note that "qID-1" is already in the localStorage and "id-does-not-exist" is not present as a question id. These values should therefore be ignored - const file = buildBookmarkFile("types_1", ["qID-1", "id-does-not-exist", "qID-4"]); + const file = buildBookmarkFile("cypress_1", ["qID-1", "id-does-not-exist", "qID-4"]); cy.get("input[type=file]") .selectFile(file, { force: true }) .should(() => { - const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("types_1"); - expect(bookmarkedLocalStorageItem?.id).to.equal("types_1"); + const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("cypress_1"); + expect(bookmarkedLocalStorageItem?.id).to.equal("cypress_1"); expect(bookmarkedLocalStorageItem?.questions).to.deep.equal(["qID-1", "qID-3", "qID-4"]); }); @@ -150,12 +210,12 @@ describe("Test import of bookmarked Questions", () => { it("should add imported items to the localStorage if there are more items in the import than the original storage", () => { cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); - const file = buildBookmarkFile("types_1", ["qID-2", "qID-4", "qID-5", "qID-6"]); + const file = buildBookmarkFile("cypress_1", ["qID-2", "qID-4", "qID-5", "qID-6"]); cy.get("input[type=file]") .selectFile(file, { force: true }) .should(() => { - const bookmarkedLocalStorageItem = getBookmarkedQuestionsFromModule("types_1"); + const bookmarkedLocalStorageItem = getBookmarkedQuestionsFromModule("cypress_1"); expect(bookmarkedLocalStorageItem).to.deep.equal(["qID-1", "qID-3", "qID-2", "qID-4", "qID-5", "qID-6"]); }); }); @@ -164,13 +224,13 @@ describe("Test import of bookmarked Questions", () => { cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); //Build file to add - const file = buildBookmarkFile("types_1", []); + const file = buildBookmarkFile("cypress_1", []); cy.get("input[type=file]") .selectFile(file, { force: true }) .should(() => { - const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("types_1"); - expect(bookmarkedLocalStorageItem?.id).to.equal("types_1"); + const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("cypress_1"); + expect(bookmarkedLocalStorageItem?.id).to.equal("cypress_1"); expect(bookmarkedLocalStorageItem?.questions).to.deep.equal(["qID-1", "qID-3"]); }); }); @@ -180,14 +240,14 @@ describe("Test import of bookmarked Questions", () => { cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); - const file = buildBookmarkFile("types_1", ["qID-2", "qID-4", "qID-5"]); + const file = buildBookmarkFile("cypress_1", ["qID-2", "qID-4", "qID-5"]); cy.get("input[type=file]") .selectFile(file, { force: true }) .should(() => { - const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("types_1"); + const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("cypress_1"); - expect(bookmarkedLocalStorageItem?.id).to.equal("types_1"); + expect(bookmarkedLocalStorageItem?.id).to.equal("cypress_1"); expect(bookmarkedLocalStorageItem?.questions).to.deep.equal(["qID-2", "qID-4", "qID-5"]); }); }); @@ -196,14 +256,14 @@ describe("Test import of bookmarked Questions", () => { cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); //Notice these values are only duplicates (but in different order that the fixture) - const file = buildBookmarkFile("types_1", ["qID-3", "qID-1"]); + const file = buildBookmarkFile("cypress_1", ["qID-3", "qID-1"]); cy.get("input[type=file]") .selectFile(file, { force: true }) .should(() => { - const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("types_1"); + const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("cypress_1"); - expect(bookmarkedLocalStorageItem?.id).to.equal("types_1"); + expect(bookmarkedLocalStorageItem?.id).to.equal("cypress_1"); expect(bookmarkedLocalStorageItem?.questions).to.deep.equal(["qID-1", "qID-3"]); }); }); @@ -214,7 +274,8 @@ describe("Test import of bookmarked Questions", () => { describe("Test deletion of bookmarked Questions", () => { //Navigate to module url and spy on console beforeEach(() => { - cy.visit("/module/types_1", { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1", { onBeforeLoad(win) { cy.spy(win.console, "log").as("consoleLog"); cy.stub(win.console, "error").as("consoleError"); @@ -226,30 +287,30 @@ describe("Test deletion of bookmarked Questions", () => { it("should delete bookmarked questions on delete click", () => { //Setup localStorage with fixture - cy.fixtureToLocalStorage("repeatio-marked-types_1.json"); + cy.fixtureToLocalStorage("repeatio-marked-cypress_1.json"); cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); cy.contains("li", "Delete") .click() .should(() => { - expect(localStorage.getItem("repeatio-marked-types_1")).to.be.null; + expect(localStorage.getItem("repeatio-marked-cypress_1")).to.be.null; }); //Check with ui cy.get("article[data-cy='Bookmarked Questions']").contains("button", "Start").click(); cy.contains("Found 0 bookmarked questions for this module!"); - cy.contains(`Deleted bookmarked questions for "types_1"!`); - cy.get("@consoleLog").should("be.calledWithMatch", /\[.*\] Deleted bookmarked questions for "types_1"\!/); + cy.contains(`Deleted bookmarked questions for "cypress_1"!`); + cy.get("@consoleLog").should("be.calledWithMatch", /\[.*\] Deleted bookmarked questions for "cypress_1"\!/); }); it("should show error if trying to delete bookmarked questions but 0 questions are defined", () => { cy.get("article[data-cy='Bookmarked Questions']").find("button.popover-button").click(); cy.contains("li", "Delete").click(); - cy.contains(`Failed to delete the bookmarked questions for "types_1" because there are 0 questions saved!`); + cy.contains(`Failed to delete the bookmarked questions for "cypress_1" because there are 0 questions saved!`); cy.get("@consoleError").should( "be.calledWithMatch", - /\[.*\] Failed to delete the bookmarked questions for "types_1" because there are 0 questions saved\!/ + /\[.*\] Failed to delete the bookmarked questions for "cypress_1" because there are 0 questions saved\!/ ); }); }); @@ -258,11 +319,19 @@ describe("Test deletion of bookmarked Questions", () => { //TODO remove this when developing v0.5 describe("Transform from Bookmark to v0.4 Test", () => { it("should replace v0.3 bookmark file structure with new v0.4 in localStorage onMount", () => { - localStorage.setItem("repeatio-marked-types_1", JSON.stringify(["qID-1", "qID-3"])); - cy.visit("module/types_1").should(() => { - const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("types_1"); + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(["qID-1", "qID-3"])); + + // Assert that the old bookmarked item was added to the localStorage + cy.get("body").should(() => { + const bookmarkedItemsOldFileStructure = parseJSON(localStorage.getItem("repeatio-marked-cypress_1")); + expect(bookmarkedItemsOldFileStructure).to.deep.equal(["qID-1", "qID-3"]); + }); + + cy.visit("module/cypress_1").should(() => { + const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem("cypress_1"); - expect(bookmarkedLocalStorageItem?.id).to.equal("types_1"); + expect(bookmarkedLocalStorageItem?.id).to.equal("cypress_1"); expect(bookmarkedLocalStorageItem?.type).to.equal("marked"); expect(bookmarkedLocalStorageItem?.compatibility).to.equal("0.4.0"); expect(bookmarkedLocalStorageItem?.questions).to.deep.equal(["qID-1", "qID-3"]); @@ -276,7 +345,7 @@ describe("Transform from Bookmark to v0.4 Test", () => { * @param moduleID - id of the module * @param questions - Array of the ids of the questions */ -export function buildBookmarkFile(moduleID: "types_1" | (string & {}), questions: IBookmarkedQuestions["questions"]) { +export function buildBookmarkFile(moduleID: "cypress_1" | (string & {}), questions: IBookmarkedQuestions["questions"]) { const fileContent = { id: moduleID, type: "marked", diff --git a/cypress/e2e/deleteModule.cy.ts b/cypress/e2e/deleteModule.cy.ts index 1481987..31dbc76 100644 --- a/cypress/e2e/deleteModule.cy.ts +++ b/cypress/e2e/deleteModule.cy.ts @@ -13,7 +13,7 @@ describe("Test deletion of module", () => { }); }); - //Test download of public folder file (cypress saves it to cypress/downloads) + //TODO delete this it("should delete module that is located in localStorage", () => { //Add item to localStorage and check existence cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); @@ -37,15 +37,6 @@ describe("Test deletion of module", () => { cy.contains("Cypress Fixture Module (cypress_1)").should("not.exist"); }); - it("should prevent deletion of example module", () => { - //Click delete module button - cy.get("article[data-cy='module-types_1']").find("button.popover-button").click(); - cy.get("ul.MuiList-root").contains("Delete").click(); - - cy.get(".Toastify").contains("Can't delete example module!"); - cy.get("@consoleWarn").should("be.calledWithMatch", /\[.*\] Can't delete example module\!/); - }); - it("should toast error if to be deleted file can't be found in localStorage ", () => { //Add fixture to localStorage cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); diff --git a/cypress/e2e/publicModule.cy.ts b/cypress/e2e/publicModule.cy.ts index bb9971b..555e7bd 100644 --- a/cypress/e2e/publicModule.cy.ts +++ b/cypress/e2e/publicModule.cy.ts @@ -1,15 +1,18 @@ /// -describe("Test the module that is provided by the public folder", () => { - beforeEach(() => { - cy.visit("/"); - }); +import { IModule } from "../../src/components/module/module"; +import { parseJSON } from "../../src/utils/parseJSON"; +import { TSettings } from "../../src/utils/types"; +describe("Test the module that is provided by the public folder", () => { it("should display module", () => { + cy.visit("/"); cy.contains("Question Types (types_1)").should("be.visible"); }); it("should answer all questions in public module", () => { + cy.visit("/"); + cy.contains("View").click(); cy.contains("Question Types (types_1)").should("be.visible"); @@ -69,6 +72,22 @@ describe("Test the module that is provided by the public folder", () => { //Check if back at beginning cy.contains("ID: qID-1"); }); + + it("should show question if directly navigating to question from an external source", () => { + // This is for example the case if the user visits the website from the docs and he has never been to the homepage + cy.visit("/module/types_1/question/qID-1?mode=practice&order=chronological"); + + // Asset that the question renders, that the module was added and that the settings were updated + cy.contains("ID: qID-1") + .should("exist") + .and(() => { + const module = parseJSON(localStorage.getItem("repeatio-module-types_1")); + expect(module).not.to.be.null; + + const settings = parseJSON(localStorage.getItem("repeatio-settings")); + expect(settings?.addedExampleModule).to.equal(true); + }); + }); }); export {}; diff --git a/cypress/e2e/question.cy.ts b/cypress/e2e/question.cy.ts new file mode 100644 index 0000000..3071352 --- /dev/null +++ b/cypress/e2e/question.cy.ts @@ -0,0 +1,69 @@ +/// + +/* These e2e tests only cover tests related to the url. All other tests are handled by component tests */ + +describe("Question", () => { + it("should show question component with mode practice and order chronological when navigating from module", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1"); + + cy.get("article[data-cy='Practice']").contains("button", "Start").click(); + cy.contains("ID: qID-1").should("exist"); + + // Assert that the url correctly updated + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=practice&order=chronological"); + }); + + it("should show question with mode practice and order random", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1"); + + cy.get("article[data-cy='Practice']").contains("button", "Random").click(); + cy.get(".question-id").should("exist"); + + // Assert that the url correctly updated + cy.url().should("include", "?mode=practice&order=random"); + }); + + it("should redirect to mode practice and order chronological if both values are undefined", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1/question/qID-1"); + + // Assert that the url correctly updated + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=practice&order=chronological"); + }); + + it("should redirect to mode practice if value is undefined", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1/question/qID-1?order=random"); + + // Assert that the url correctly updated + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=practice&order=random"); + }); + + it("should redirect to order chronological if value is undefined", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1/question/qID-1?mode=practice"); + + // Assert that the url correctly updated + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=practice&order=chronological"); + }); + + it("should redirect to mode practice if the value is not practice or bookmarked", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1/question/qID-1?mode=incorrectValue&order=chronological"); + + // Assert that the url correctly updated + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=practice&order=chronological"); + }); + + it("should redirect to order chronological if the value is not practice or bookmarked", () => { + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.visit("/module/cypress_1/question/qID-1?mode=practice&order=incorrectValue"); + + // Assert that the url correctly updated + cy.url().should("include", "/module/cypress_1/question/qID-1?mode=practice&order=chronological"); + }); +}); + +export {}; diff --git a/cypress/e2e/updateQuestion.cy.ts b/cypress/e2e/updateQuestion.cy.ts index 91e5ff1..2798813 100644 --- a/cypress/e2e/updateQuestion.cy.ts +++ b/cypress/e2e/updateQuestion.cy.ts @@ -197,11 +197,11 @@ describe("Update question using mode random", () => { cy.get("div.ReactModal__Overlay").scrollIntoView(); }); - it("should show warning if trying to update question", () => { + it("should update a question using mode random", () => { + // Update points to 50 (by adding 0 to the end of the input which already had 5 asa value) + cy.get("input[name='points']").type("0"); cy.contains("button", "Update").click(); - cy.contains( - "Can't edit this questions while using mode random! Navigate to this question using the question overview." - ).should("exist"); + cy.contains("50 Points").should("exist"); }); }); @@ -213,7 +213,7 @@ describe("Updating a question of type gap-text", () => { }); it("should show correct value when editing the question and update value", () => { - cy.visit("/module/gap_text/question/gt-1"); + cy.visit("/module/gap_text/question/gt-1?mode=practice&order=chronological"); cy.get("button[aria-label='Edit Question']").click(); cy.get("textarea#editor-gap-text-textarea") .should("have.text", "This is the [first] question") @@ -239,7 +239,7 @@ describe("Updating a question of type gap-text", () => { }); it("should show correct textarea value if gap text contains multiple gaps and multiple correct values", () => { - cy.visit("/module/gap_text/question/gt-3"); + cy.visit("/module/gap_text/question/gt-3?mode=practice&order=chronological"); cy.get("button[aria-label='Edit Question']").click(); cy.get("textarea#editor-gap-text-textarea") @@ -267,7 +267,7 @@ describe("Updating a question of type gap-text", () => { }); it("should remove gap from gap-text", () => { - cy.visit("/module/gap_text/question/gt-3"); + cy.visit("/module/gap_text/question/gt-3?mode=practice&order=chronological"); cy.get("button[aria-label='Edit Question'").click(); cy.get("textarea#editor-gap-text-textarea").setSelection("[contains]").type("{del}").type("{del}"); @@ -287,7 +287,7 @@ describe("Updating a question of type gap-text", () => { }); it("should load and update the correct gap-text to edit", () => { - cy.visit("/module/gap_text/question/gt-2"); + cy.visit("/module/gap_text/question/gt-2?mode=practice&order=chronological"); cy.get("button[aria-label='Navigate to next Question'").click(); cy.get("button[aria-label='Edit Question'").click(); diff --git a/cypress/fixtures/repeatio-marked-cypress_1.json b/cypress/fixtures/repeatio-marked-cypress_1.json new file mode 100644 index 0000000..b1aec3d --- /dev/null +++ b/cypress/fixtures/repeatio-marked-cypress_1.json @@ -0,0 +1,9 @@ +{ + "id": "cypress_1", + "type": "marked", + "compatibility": "0.4.0", + "questions": [ + "qID-1", + "qID-3" + ] +} \ No newline at end of file diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 8f5bdfb..f572512 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -38,6 +38,7 @@ type Fixtures = | "repeatio-module-empty-questions.json" | "repeatio-module-cypress_1.json" | "repeatio-marked-types_1.json" + | "repeatio-marked-cypress_1.json" | (string & {}); declare global { diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index b68e7f6..7b25f99 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { Link, LinkProps } from "react-router-dom"; import { memo } from "react"; //css @@ -35,14 +35,14 @@ export const Card = memo(({ type, disabled, title, description, icon, children, /* ----------------------------------------- LINK -------------------------------------------- */ //!This extend might be false interface ILinkElement extends React.RefAttributes { - linkTo: string; + linkTo: LinkProps["to"]; linkAriaLabel: string; linkText: string; } //!URL might not work with special characters (äöß/#....) //Link Element Component -export const LinkElement = ({ linkTo, linkAriaLabel, linkText, ...props }: ILinkElement) => { +export const LinkElement: React.FC = ({ linkTo, linkAriaLabel, linkText, ...props }) => { return ( {linkText} diff --git a/src/components/Home/CreateModule.cy.tsx b/src/components/Home/CreateModule.cy.tsx index 12f1d48..79d3f8b 100644 --- a/src/components/Home/CreateModule.cy.tsx +++ b/src/components/Home/CreateModule.cy.tsx @@ -173,25 +173,6 @@ describe("Creating a module", () => { cy.get("select#create-module-language-select").should("not.have.class", "is-invalid"); }); - //Error when using reserved keywords (module/types_1) - it('should show error if provided id for module is a reserved keyword ("module"/"types_1")', () => { - cy.mount(); - - //Check against word "module" - cy.get("input#create-module-id-input").type("module"); - cy.get("input#create-module-name-input").type("Module created with cypress", { delay: 2 }); - cy.get("select#create-module-language-select").select("English"); - cy.contains("button", "Create").click(); - - cy.contains(`The word "module" is a reserved keyword and can't be used inside an ID!`).should("be.visible"); - cy.get("input#create-module-id-input").should("have.class", "is-invalid"); - - //Check against word "types_1" as that value is used for the repeatio examples - cy.get("input#create-module-id-input").clear().type("types_1"); - cy.contains(`The word "types_1" is a reserved keyword!`).should("be.visible"); - cy.get("input#create-module-id-input").should("have.class", "is-invalid"); - }); - //Error if using space inside id input it("should show error if using space in id", () => { cy.mount(); diff --git a/src/components/Home/CreateModule.tsx b/src/components/Home/CreateModule.tsx index 2c22859..d5fd8f0 100644 --- a/src/components/Home/CreateModule.tsx +++ b/src/components/Home/CreateModule.tsx @@ -43,38 +43,24 @@ export const CreateModule = ({ handleModalClose }: ICreateModule) => { //Runs only after first submit and after that on every onChange //The ID: // - 1. should not include word module - // - 2. should not equal types_1 - // - 3. should not include any space character - // - 4. should match url requirements - // - 5. should not already exist (be unique) + // - 2. should not include any space character + // - 3. should match url requirements + // - 4. should not already exist (be unique) const validateID = (value: string) => { //1. Check if id includes the word module as it is a reserved keyword (to split the ) if (value.includes("module")) { return `The word "module" is a reserved keyword and can't be used inside an ID!`; } - //2. Check if id is reserved "types_1" (represents example ids) - if (value === "types_1") { - return `The word "${value}" is a reserved keyword!`; - } - - //3. Filter out space character + //2. Filter out space character const spaceRegex = / /g; const spaces = value.match(spaceRegex)?.join(""); if (spaces && spaces?.length > 0) { return `The ID has to be one word! Use hyphens ("-") to concat the word (${value.replace(/ /g, "-")})`; - /* - return ( - <> - <>The ID has to be one word! -
Use hyphens ("-") to concat the word ({value.replace(/ /g, "-")}) - - ); - */ } - //4. Check for only allowed (url) characters + //3. Check for only allowed (url) characters //Filter out everything that is not: a-z, A-Z, 0-9, ä-Ü const regex = /[^a-zA-Z0-9-ß_~.\u0080-\uFFFF]/g; const notAllowedChars = value @@ -86,7 +72,7 @@ export const CreateModule = ({ handleModalClose }: ICreateModule) => { return `The id contains non allowed characters (${notAllowedChars})`; } - //5. Check if module id is duplicate + //4. Check if module id is duplicate if (moduleAlreadyInStorage(value)) { return `ID of module ("${value}") already exists!`; } diff --git a/src/components/Home/Home.cy.tsx b/src/components/Home/Home.cy.tsx index 986ac86..147e787 100644 --- a/src/components/Home/Home.cy.tsx +++ b/src/components/Home/Home.cy.tsx @@ -1,16 +1,23 @@ import Home from "../../pages/index"; import "../../index.css"; import { MemoryRouter, Route } from "react-router-dom"; +import { parseJSON } from "../../utils/parseJSON"; +import { CustomToastContainer } from "../toast/toast"; + +// Interfaces / Types +import { TSettings } from "../../utils/types"; +import { IModule } from "../module/module"; declare var it: Mocha.TestFunction; declare var describe: Mocha.SuiteFunction; -//declare const expect: Chai.ExpectStatic; +declare const expect: Chai.ExpectStatic; const MockModulesWithRouter = () => { return (
+
); @@ -75,13 +82,38 @@ describe("Modules (Home) component", () => { cy.get("div.ReactModal__Content--after-open").should("not.exist"); }); - it("should render modules from localStorage and public folder", () => { + it("should add the default module to the localStorage if there is to settings item in the localStorage", () => { + cy.mount(); + + cy.get("article.card") + .should("have.length", 1) + .should(() => { + const settings = parseJSON(localStorage.getItem("repeatio-settings")); + expect(settings?.addedExampleModule).to.equal(true); + }); + }); + + it("should not add the default module to the localStorage if the module was previously added to the module but the module was deleted (settings.addedExampleModule === false)", () => { + cy.mount(); + + // Add settings to show that the item was already added + localStorage.setItem("repeatio-settings", JSON.stringify({ addedExampleModule: true })); + + cy.get("article.card") + .should("not.exist") + .should(() => { + const settings = parseJSON(localStorage.getItem("repeatio-settings")); + expect(settings?.addedExampleModule).to.equal(true); + }); + }); + + it("should render modules from the localStorage", () => { cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); cy.fixtureToLocalStorage("repeatio-module-gap_text.json"); cy.mount(); - cy.get("article.card").should("have.length", 3); + cy.get("article.card").should("have.length", 3); //The third module is the example module that gets automatically added on first ever visit // Assert that the correct amount of questions get counted cy.get("article[data-cy='module-gap_text'").scrollIntoView().contains("p", "10 Questions").should("exist"); @@ -102,4 +134,73 @@ describe("Modules (Home) component", () => { }); }); +/* Module Deletion */ +describe("Module deletion", () => { + it("should delete module that is located in localStorage", () => { + cy.mount(); + + //Add item to localStorage and check existence + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + cy.contains("Cypress Fixture Module (cypress_1)").should("exist"); + + //Click delete module button + cy.get("article[data-cy='module-cypress_1']").find("button.popover-button").click(); + cy.get("ul.MuiList-root") + .contains("Delete") + .click() + .should(() => { + //Delete from localStorage + expect(localStorage.getItem("repeatio-module-cypress_1")).to.equal(null); + }); + + //Toast + cy.get(".Toastify").contains("Deleted module cypress_1!"); + + //Module should no longer exist/be visible in list of modules + cy.contains("Cypress Fixture Module (cypress_1)").should("not.exist"); + }); + + it("should delete example module", () => { + cy.mount(); + + // Add another module + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + + //Click delete module button + cy.get("article[data-cy='module-types_1']").find("button.popover-button").click(); + cy.get("ul.MuiList-root") + .contains("Delete") + .click() + .should(() => { + const module = parseJSON(localStorage.getItem(`repeatio-module-types_1`)); + expect(module).to.equal(null); + }); + + // Assert the example module to no longer be present on the page + cy.contains("h2", "Question Types (types_1)").should("not.exist"); + + // Assert that the other module is unaffected be the deletion and still exist in the DOM + cy.contains("h2", "Cypress Fixture Module (cypress_1)").should("exist"); + }); + + it("should toast error if to be deleted file can't be found in localStorage ", () => { + cy.mount(); + + //Add fixture to localStorage + cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); + + //Click on dots + cy.get(`article[data-cy='module-cypress_1']`).find("button.popover-button").click(); + + //remove item from localStorage so it can't be found to simulate not existing file + cy.clearLocalStorage("repeatio-module-cypress_1"); + + //Click on export + cy.get(".MuiList-root").contains("Delete").click({ force: true }); + + //Expect toast to show up + cy.get(".Toastify").contains("Couldn't find the file repeatio-module-cypress_1 in the localStorage!"); + }); +}); + // TODO maybe implement the tests from deleteModule.cy.ts and exportModule.cy.ts into here ?? diff --git a/src/components/Home/Modules.tsx b/src/components/Home/Modules.tsx index 06a3d14..542c72b 100644 --- a/src/components/Home/Modules.tsx +++ b/src/components/Home/Modules.tsx @@ -1,7 +1,6 @@ import { useState, useLayoutEffect, useEffect, useCallback } from "react"; import isElectron from "is-electron"; import { toast } from "react-toastify"; -import { fetchModuleFromPublicFolder } from "../../utils/fetchModuleFromPublicFolder"; //Components import { GridCards } from "../GridCards/GridCards"; @@ -17,9 +16,11 @@ import { BiTrash } from "react-icons/bi"; //Functions import { saveFile } from "../../utils/saveFile"; import { parseJSON } from "../../utils/parseJSON"; +import { addExampleModuleToLocalStorage, isExampleModuleAdded } from "./helpers"; //Interfaces and Types import { IModule } from "../module/module"; +import { TSettings } from "../../utils/types"; //Component export const Modules = () => { @@ -50,7 +51,7 @@ export const Modules = () => { > @@ -74,7 +75,15 @@ const useAllModules = () => { //Get the modules from the localStorage and set the module state //Updates every time localeStorage changes const modulesFromBrowserStorage = useCallback(async () => { - //Setup variables for the module and possible errors + // Get settings from localStorage + const settings = parseJSON(localStorage.getItem("repeatio-settings")); + + // Add example module to localStorage if it isn't there and the user hasn't removed it (first ever render) + if (!isExampleModuleAdded(settings)) { + await addExampleModuleToLocalStorage(settings); + } + + //Setup variables for the module let localStorageModules: IModule[] = []; Object.entries(localStorage).forEach((key) => { @@ -94,18 +103,8 @@ const useAllModules = () => { } }); - //get the data from the public folder (types_1) - const dataFromPublicFolder = await fetchModuleFromPublicFolder(); - - //When able to fetch the data from the public folder, combine them else just show localStorage. - //This is useful for when the user is offline - if (dataFromPublicFolder !== undefined) { - setModules([...localStorageModules, dataFromPublicFolder]); - } else { - setModules(localStorageModules); - } - //Update states + setModules(localStorageModules); setLoading(false); }, []); @@ -168,13 +167,6 @@ const useHomePopover = () => { //Get id of module by custom attribute const moduleID = anchorEl?.getAttribute("data-target"); - //Prevent deletion of example module as that is saved in the public folder - if (moduleID === "types_1") { - toast.warn("Can't delete example module!"); - handlePopoverClose(); - return; - } - //Prevent deletion if using electron if (isElectron()) { toast.warn("Can't delete modules from electron!"); @@ -207,15 +199,7 @@ const useHomePopover = () => { //Get id of the module from the button const moduleID = anchorEl?.getAttribute("data-target"); - let file; - - if (moduleID !== "types_1") { - //Get module item from localStorage - file = localStorage.getItem(`repeatio-module-${moduleID}`); - } else { - const publicModule = await fetchModuleFromPublicFolder(); - file = JSON.stringify(publicModule, null, "\t"); - } + const file = localStorage.getItem(`repeatio-module-${moduleID}`); if (file) { await saveFile({ file: file, name: `repeatio-module-${moduleID}` }); diff --git a/src/components/Home/helpers.ts b/src/components/Home/helpers.ts index 12f9bbd..903a5fb 100644 --- a/src/components/Home/helpers.ts +++ b/src/components/Home/helpers.ts @@ -1,3 +1,5 @@ +import { fetchModuleFromPublicFolder } from "../../utils/fetchModuleFromPublicFolder"; +import { TSettings } from "../../utils/types"; import { IFile } from "./ImportModule"; export function moduleAlreadyInStorage(value: string) { @@ -20,3 +22,32 @@ export async function getFileTypeAndID(file: File): Promise = () => { answerCorrect, questionDataRef, questionAnswerRef, + fetchQuestion, + setShowAnswer, } = useQuestion(); //JSX @@ -67,27 +69,21 @@ export const Question: React.FC<{}> = () => { /> {/* -- Question Bottom includes checking/reset/actions(delete/edit/save) and the navigation --*/} ); }; -// TODO maybe use Pick instead type TQuestionData = Pick< TUseQuestion, "question" | "loading" | "questionAnswerRef" | "questionDataRef" | "showAnswer" | "answerCorrect" >; -/* question: IQuestion | undefined; - loading: boolean; - showAnswer: boolean; - answerCorrect: boolean; - questionDataRef: React.RefObject; - questionAnswerRef: React.RefObject; -} */ //Question Data contains all the question info (title, points, type, help, answerOptions) const QuestionData: React.FC = ({ @@ -136,17 +132,21 @@ const QuestionData: React.FC = ({ //QuestionBottom contains the checking/resetting of a question as well as the actions (save, edit, delete) and the navigation export interface IQuestionBottom { - questionID: IQuestion["id"] | undefined; + question: IQuestion | undefined; showAnswer: TUseQuestion["showAnswer"]; disabled: boolean; handleResetRetryQuestion: TUseQuestion["handleResetRetryQuestion"]; + fetchQuestion: TUseQuestion["fetchQuestion"]; + setShowAnswer: TUseQuestion["setShowAnswer"]; } export const QuestionBottom: React.FC = ({ - questionID, + question, showAnswer, disabled, handleResetRetryQuestion, + fetchQuestion, + setShowAnswer, }) => { //States const [showNav, setShowNav] = useState(false); @@ -190,10 +190,15 @@ export const QuestionBottom: React.FC = ({ className={`question-actions-navigation-wrapper ${collapsedActionsNav ? "collapsed" : ""}`} data-testid='question-actions-navigation-wrapper' > - - - - + + + + )} @@ -203,7 +208,10 @@ export const QuestionBottom: React.FC = ({ //ID and Progress of the current question const QuestionIdProgress = memo(({ qID }: { qID: IQuestion["id"] }) => { //Context - const { filteredQuestions } = useContext(ModuleContext); //TODO remove this + const { questionIds } = useContext(QuestionIdsContext); + + // Get current index + const currentIndex = questionIds?.findIndex((item) => item === qID); return (
@@ -211,7 +219,9 @@ const QuestionIdProgress = memo(({ qID }: { qID: IQuestion["id"] }) => { ID: {qID}

- {filteredQuestions?.findIndex((item) => item.id === qID) + 1}/{filteredQuestions?.length || "?"} Questions + {/* Display "index + 1 / Questions.length Questions"*/} + {typeof currentIndex !== "undefined" && currentIndex >= 0 && currentIndex + 1}/{questionIds?.length || "?"}{" "} + Questions

); diff --git a/src/components/Question/QuestionTypes/GapText/GapText.cy.tsx b/src/components/Question/QuestionTypes/GapText/GapText.cy.tsx index c019ab1..54065a0 100644 --- a/src/components/Question/QuestionTypes/GapText/GapText.cy.tsx +++ b/src/components/Question/QuestionTypes/GapText/GapText.cy.tsx @@ -1,7 +1,7 @@ /// import { MemoryRouter, Route } from "react-router-dom"; -import { ModuleProvider } from "../../../module/moduleContext"; +import { QuestionIdsProvider } from "../../../module/questionIdsContext"; import { Question } from "../../Question"; import { GapText } from "./GapText"; @@ -238,11 +238,11 @@ describe("GapText", () => { //Setup Router to access context and useParams const RenderQuestionWithRouter = ({ moduleID, questionID }: Required) => { return ( - +
- + - +
); @@ -499,6 +499,41 @@ describe("Gap Text component inside Question component", () => { cy.contains("Yes, that's correct!").should("exist"); }); + it("should clear the question correction on submit and navigating with Question Navigation (button[aria-label='Navigate to next Question']) instead of submitting the question again", () => { + cy.mount(); + + // Type into the input + cy.get("input#input-0").type("first", { delay: 2 }); + + // Submit the question + cy.get("button[aria-label='Check Question']").click(); + + // Click show navigation button that just exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Navigate to new site + cy.get("button[aria-label='Navigate to next Question']").click(); + + // Assert that the input has empty value after navigation and is enabled + cy.get("input#input-0").should("have.value", "").and("not.be.disabled"); + + // Assert that the question correction went away + cy.get("section.question-correction").should("not.exist"); + + // Type correct answer + cy.get("input#input-0").type("second", { delay: 2 }).should("have.value", "second"); + + // Submit question + cy.get("button[type='submit']").click(); + + // Check correction + cy.contains("Yes, that's correct!").should("exist"); + }); + //BORDERS it("should render green border on input element if the answer if correct", () => { cy.mount(); diff --git a/src/components/Question/QuestionTypes/GapTextDropdown/GapTextDropdown.cy.tsx b/src/components/Question/QuestionTypes/GapTextDropdown/GapTextDropdown.cy.tsx index e255621..1fd6974 100644 --- a/src/components/Question/QuestionTypes/GapTextDropdown/GapTextDropdown.cy.tsx +++ b/src/components/Question/QuestionTypes/GapTextDropdown/GapTextDropdown.cy.tsx @@ -1,7 +1,7 @@ /// import { MemoryRouter, Route } from "react-router-dom"; -import { ModuleProvider } from "../../../module/moduleContext"; +import { QuestionIdsProvider } from "../../../module/questionIdsContext"; import { Question } from "../../Question"; import { GapTextDropdown } from "./GapTextDropdown"; @@ -168,11 +168,11 @@ describe("GapTextDropdown Component", () => { //Setup Router to access context and useParams const RenderQuestionWithRouter = ({ moduleID, questionID }: Required) => { return ( - +
- + - +
); @@ -443,6 +443,38 @@ describe("Gap Text with Dropdown component inside Question component", () => { cy.get(".question-correction").contains("fixture").should("exist"); }); + it("should should clear the question correction after question submit if the user navigates to the next question using the QuestionNavigation (button[aria-label='Navigate to next Question']) instead of navigating by submitting the question again", () => { + cy.mount(); + + cy.get("select").first().select(0); + + // Submit the question + cy.get("button[aria-label='Check Question']").click(); + + // Click show navigation button that just exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Navigate to new site + cy.get("button[aria-label='Navigate to next Question']").click(); + + // Select in new question should be empty and enabled + cy.get("select").first().should("have.value", "").and("not.be.disabled"); + + // Assert that the question correction went away + cy.get("section.question-correction").should("not.exist"); + + cy.get("select#select-0").select("second"); + // Submit question + cy.get("button[type='submit']").click(); + + // Check correction + cy.contains("Yes, that's correct!").should("exist"); + }); + it("should work after moving from a question with one input to multiple inputs", () => { cy.mount(); diff --git a/src/components/Question/QuestionTypes/MultipleChoice/MultipleChoice.cy.tsx b/src/components/Question/QuestionTypes/MultipleChoice/MultipleChoice.cy.tsx index 59b85d7..1914f6a 100644 --- a/src/components/Question/QuestionTypes/MultipleChoice/MultipleChoice.cy.tsx +++ b/src/components/Question/QuestionTypes/MultipleChoice/MultipleChoice.cy.tsx @@ -1,7 +1,7 @@ /// import { MemoryRouter, Route } from "react-router-dom"; -import { ModuleProvider } from "../../../module/moduleContext"; +import { QuestionIdsProvider } from "../../../module/questionIdsContext"; import { Question } from "../../Question"; import { MultipleChoice } from "./MultipleChoice"; @@ -98,11 +98,11 @@ describe("MultipleChoice Component", () => { //Setup Router to access context and useParams const RenderQuestionWithRouter = ({ moduleID, questionID }: Required) => { return ( - +
- + - +
); @@ -354,6 +354,37 @@ describe("Multiple Choice component inside Question component", () => { .should("have.text", "This is the correct multiple choice value"); }); + it("should clear the question correction after question submit if the user navigates to the next question using the QuestionNavigation (button[aria-label='Navigate to next Question']) instead of submitting the question again", () => { + cy.mount(); + + // Submit the question + cy.get("button[aria-label='Check Question']").click(); + + // Click show navigation button that just exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Navigate to new site + cy.get("button[aria-label='Navigate to next Question']").click(); + + // Assert that none of the elements are disabled + cy.get(".question-multiple-choice").find("input.Mui-disabled").should("have.length", 0); + + // Assert that the question correction went away + cy.get("section.question-correction").should("not.exist"); + + // Check correct answer + cy.get("section.question-user-response").contains("Correct").click(); + cy.get("button[aria-label='Check Question']").click(); + + // Check correction + cy.contains("Yes, that's correct!").should("exist"); + cy.get("ul.correction-multipleChoice-list").contains("Correct").should("exist"); + }); + it("should outline correct answer in green after submit if user selection is correct", () => { cy.mount(); diff --git a/src/components/Question/QuestionTypes/MultipleResponse/MultipleResponse.cy.tsx b/src/components/Question/QuestionTypes/MultipleResponse/MultipleResponse.cy.tsx index eb80b51..348fabf 100644 --- a/src/components/Question/QuestionTypes/MultipleResponse/MultipleResponse.cy.tsx +++ b/src/components/Question/QuestionTypes/MultipleResponse/MultipleResponse.cy.tsx @@ -4,7 +4,7 @@ import { MultipleResponse } from "./MultipleResponse"; import { Question } from "../../Question"; import { MemoryRouter, Route } from "react-router-dom"; -import { ModuleProvider } from "../../../module/moduleContext"; +import { QuestionIdsProvider } from "../../../module/questionIdsContext"; import "../../../../index.css"; import "../../Question.css"; @@ -107,11 +107,11 @@ describe("Multiple Response component", () => { //Setup Router to access context and useParams const RenderQuestionWithRouter = ({ moduleID, questionID }: Required) => { return ( - +
- + - +
); @@ -364,6 +364,37 @@ describe("MultipleResponse Component rendered inside Question Component with Rou cy.get(".question-correction").contains("li", "This is another correct multiple response value").should("exist"); }); + it("should clear the question correction after question submit if the user navigates to the next question using the QuestionNavigation (button[aria-label='Navigate to next Question']) instead of navigating by submitting the question again ", () => { + cy.mount(); + + // Submit the question + cy.get("button[aria-label='Check Question']").click(); + + // Click show navigation button that just exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Navigate to new site + cy.get("button[aria-label='Navigate to next Question']").click(); + + // Assert that none of the elements are disabled + cy.get(".question-multiple-response").find("input.Mui-disabled").should("have.length", 0); + + // Assert that the question correction went away + cy.get("section.question-correction").should("not.exist"); + + // Check correct answer + cy.get("section.question-user-response").contains("Correct").click(); + cy.get("button[aria-label='Check Question']").click(); + + // Check correction + cy.contains("Yes, that's correct!").should("exist"); + cy.get("ul.correction-multipleResponse-list").contains("Correct").should("exist"); + }); + it("should outline correct answer in green after submit if user selection is correct", () => { cy.mount(); diff --git a/src/components/Question/__tests__/Question.test.tsx b/src/components/Question/__tests__/Question.test.tsx index e25174b..ccec80e 100644 --- a/src/components/Question/__tests__/Question.test.tsx +++ b/src/components/Question/__tests__/Question.test.tsx @@ -1,11 +1,12 @@ import { screen, render, cleanup } from "@testing-library/react"; import user from "@testing-library/user-event"; import { Question } from "../Question"; -import { IModuleContext, ModuleContext } from "../../module/moduleContext"; +import { IQuestionIdsContext, QuestionIdsContext } from "../../module/questionIdsContext"; import { Router, Route, Switch, MemoryRouter, RouteComponentProps } from "react-router-dom"; import { createMemoryHistory } from "history"; import { IQuestion } from "../useQuestion"; import { IModule } from "../../module/module"; +import { ISearchParams } from "../../../utils/types"; //Question test data const mockFilteredQuestions: IModule["questions"] = [ @@ -72,25 +73,30 @@ const data: IModule = { questions: mockFilteredQuestions, }; -const mockSetContextModuleID = jest.fn(); +const mockSetQuestionIds = jest.fn(); /* Mocks */ //Mock the question component with router to allow switching pages and the provider -const MockQuestionWithRouter = ({ qID, practiceMode }: { qID: IQuestion["id"]; practiceMode: string }) => { +interface IMockQuestionWithRouter { + qID: IQuestion["id"]; + mode: NonNullable; + order: NonNullable; +} + +const MockQuestionWithRouter: React.FC = ({ qID, mode, order }) => { return ( - + - question.id), + setQuestionIds: mockSetQuestionIds, + } as IQuestionIdsContext } > - + ); @@ -100,17 +106,16 @@ const MockQuestionWithRouterAndHistory = ({ history }: { history: RouteComponent return ( - question.id), + setQuestionIds: mockSetQuestionIds, + } as IQuestionIdsContext } > - + ); @@ -149,8 +154,56 @@ jest.mock("../../../hooks/useSize.ts", () => ({ useSize: () => ({ x: 10, y: 15, width: 917, height: 44, top: 15, right: 527, bottom: 59, left: 10 }), })); +/* Mock localStorage, taken from https://robertmarshall.dev/blog/how-to-mock-local-storage-in-jest-tests/ */ +interface LocalStorageMock { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + clear: () => void; + removeItem: (key: string) => void; + getAll: () => Record; +} + +const localStorageMock: LocalStorageMock = (function () { + let store: Record = {}; + + return { + getItem(key) { + return store[key]; + }, + + setItem(key, value) { + store[key] = value; + }, + + clear() { + store = {}; + }, + + removeItem(key) { + delete store[key]; + }, + + getAll() { + return store; + }, + }; +})(); + +Object.defineProperty(window, "localStorage", { value: localStorageMock }); + /* TESTING */ describe("", () => { + beforeEach(() => { + window.localStorage.clear(); + + //mock scroll functions + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + window.HTMLElement.prototype.scrollTo = jest.fn(); + + // Setup localStorage + window.localStorage.setItem("repeatio-module-Test-1", JSON.stringify(data)); + }); + afterEach(() => { cleanup(); jest.resetAllMocks(); @@ -159,12 +212,8 @@ describe("", () => { /* UNIT TESTING */ it("should render form with questionID, questionTitle, questionPoints and questionTypeHelp", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); //Expect questionID const questionIDElement = screen.getByTestId("question-id"); @@ -185,7 +234,7 @@ describe("", () => { }); it("should render the question with the id of qID-2 when given the correct params", () => { - render(); + render(); const idElement = screen.getByText("ID: qID-2"); expect(idElement).toBeInTheDocument(); @@ -194,12 +243,8 @@ describe("", () => { /* INTEGRATION TESTING */ //Expect submit button to submit and lock answers it("should disable form after submit", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); let checkOptionElement = screen.getByTestId("question-check"); user.click(checkOptionElement); @@ -209,12 +254,8 @@ describe("", () => { //Expect the correction box (red or green) to show up after question submit it("should show question correction after submit", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); const questionCorrectionElement = screen.queryByTestId("question-correction"); @@ -231,12 +272,8 @@ describe("", () => { //Expect the question correction to be red it("should show question correction as false (red)", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); const checkButtonElement = screen.getByTestId("question-check"); user.click(checkButtonElement); @@ -250,12 +287,8 @@ describe("", () => { //Expect the question correction to be green it("should show the question correction as correct (green)", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); const correctElement = screen.getByText( "Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptas voluptatibus quibusdam magnam." @@ -274,12 +307,8 @@ describe("", () => { //Expect the retry button to work and interact with the form afterwards it("should reset the form when clicking the retry button", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); const checkButtonElement = screen.getByTestId("question-check"); user.click(checkButtonElement); @@ -312,12 +341,8 @@ describe("", () => { //Expect the next question to render if the first answer is answered and next button is clicked it("should go to the next question when clicking the next button and practice mode is chronological", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); //Check Question const checkQuestionButton = screen.getByTestId("question-check"); @@ -332,12 +357,8 @@ describe("", () => { }); it("should go to a new random question when clicking the next button and practice mode is random", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //render element - render(); + render(); //Check Question const checkQuestionButton = screen.getByTestId("question-check"); @@ -355,7 +376,7 @@ describe("", () => { //Expect the first element of the data array to render after last element is reached it("should render the first question after clicking next on the last question (go to first position of array)", () => { const idOfLastElementInTestArray = data.questions[data.questions.length - 1].id; - render(); + render(); let idElement = screen.getByText(`ID: ${idOfLastElementInTestArray}`); expect(idElement).toBeInTheDocument(); @@ -377,7 +398,7 @@ describe("", () => { //Expect the last element of the data array to render after previous button is clicked on first element it("should render the last question after clicking previous on the first question (go to last position in array)", () => { const idOfFirstElementInTestArray = data.questions[0].id; - render(); + render(); let idElement = screen.getByText(`ID: ${idOfFirstElementInTestArray}`); expect(idElement).toBeInTheDocument(); @@ -392,7 +413,7 @@ describe("", () => { //Expect question reveal no to be visible it("should not render question correction element when clicking on next question button", () => { - render(); + render(); //Click check button to reveal question navigation const buttonElement = screen.getByTestId("question-check"); @@ -411,7 +432,7 @@ describe("", () => { }); it("should not render question correction element when clicking on next button in question navigation", () => { - render(); + render(); //Click check button to reveal question navigation const checkQuestionButton = screen.getByTestId("question-check"); @@ -427,7 +448,7 @@ describe("", () => { //Expect highlight selection to go away when going to next question it("should deselect selection when going to next question but a answer was selected (clicked)", () => { - render(); + render(); //select an element const selectedElement = screen.getByText("Lorem ipsum dolor sit amet consectetur adipisicing."); @@ -449,7 +470,7 @@ describe("", () => { //Expect highlight selection to go away when going to next question it("should deselect selection when going to next question after submitting answer and clicking next", () => { - render(); + render(); //select an element const selectedElement = screen.getByText("Lorem ipsum dolor sit amet consectetur adipisicing."); @@ -475,7 +496,7 @@ describe("", () => { //Expect to render gap text question it("should render gap text question", () => { - render(); + render(); expect(screen.getByTestId("question-id")).toHaveTextContent("ID: qID-3"); expect(screen.getByText("Fill in the blanks.")).toBeInTheDocument(); @@ -484,7 +505,7 @@ describe("", () => { //Expect the gap inputs to clear when clicking the reset button (before form submit) it("should reset the inputs in gap text to empty when clicking the reset button", () => { - render(); + render(); //Type into the input elements let inputElements = screen.getAllByRole("textbox") as HTMLInputElement[]; @@ -500,7 +521,7 @@ describe("", () => { //Expect the gap inputs to clear when clicking the reset button (after form submit) it("should reset the inputs in gap text to empty when clicking the reset button after form submit", () => { - render(); + render(); //Type into the input elements let inputElements = screen.getAllByRole("textbox") as HTMLInputElement[]; @@ -519,7 +540,7 @@ describe("", () => { //Expect to trim the gap text input to be trimmed it("should trim the input values when checking the answer", () => { - render(); + render(); //Type into the input elements let inputElements = screen.getAllByRole("textbox"); @@ -536,7 +557,7 @@ describe("", () => { //Expect gap text to accept different correct values it("should render question correct when providing two different values to gap text which both could be correct", () => { - render(); + render(); //Type into the input elements (Don't want to setup a data-testid, so we grap the element by the index) let inputElements = screen.getAllByRole("textbox"); @@ -569,14 +590,14 @@ describe("", () => { }); it("should render QuestionNotFound Component if the provided ID isn't found", () => { - render(); + render(); const questionNotFoundTitle = screen.getByText("Question not found!"); expect(questionNotFoundTitle).toBeInTheDocument(); }); it("should disable buttons when Question isn't found", () => { - render(); + render(); const questionNotFoundTitle = screen.getByText("Question not found!"); expect(questionNotFoundTitle).toBeInTheDocument(); @@ -585,15 +606,11 @@ describe("", () => { //Test the history hook Expect the url to change to new params when checking a question //Expect the history hook and ui to update on next button click it("should update the url (useHistory hook) when clicking the next button and update the ui", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //Create history const history = createMemoryHistory(); history.push({ pathname: `/module/${data.id}/question/${data.questions[0].id}`, - search: "?mode=chronological", + search: "?mode=practice&order=chronological", }); //render Component with history prop @@ -616,14 +633,10 @@ describe("", () => { //Expect the url to change to previous element in array it("should go to previous url when clicking the previous question button", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - const history = createMemoryHistory(); history.push({ pathname: `/module/${data.id}/question/${data.questions[1].id}`, - search: "?mode=chronological", + search: "?mode=practice&order=chronological", }); render(); @@ -636,15 +649,11 @@ describe("", () => { //Expect the array to restart at the last element when clicking the previous question button when on the first element on the array (only test the url not UI) it("should restart the array when clicking previous question on the first element in the array", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - //Create history prop (data.questions[0].id === "qID-1") const history = createMemoryHistory(); history.push({ pathname: `/module/${data.id}/question/${data.questions[0].id}`, - search: "?mode=chronological", + search: "?mode=practice&order=chronological", }); render(); @@ -658,12 +667,8 @@ describe("", () => { //Expect the url to change to first element in array when clicking the to first Question Button it("should go to the first url in array when clicking the to first Question Button", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - const history = createMemoryHistory(); - history.push(`/module/${data.id}/question/${data.questions[1].id}`); //data.questions[1].id === "qID-2" + history.push(`/module/${data.id}/question/${data.questions[1].id}?mode=practice&order=chronological`); //data.questions[1].id === "qID-2" render(); @@ -677,12 +682,8 @@ describe("", () => { //Expect the url to change to the last element in array when clicking the to last Question Button it("should go to the last url in array when clicking the to last Question Button", () => { - //mock scroll functions - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.scrollTo = jest.fn(); - const history = createMemoryHistory(); - history.push(`/module/${data.id}/question/${data.questions[0].id}`); + history.push(`/module/${data.id}/question/${data.questions[0].id}?mode=practice&order=chronological`); render(); //Click the to last Question Button diff --git a/src/components/Question/components/Actions/BookmarkQuestion.cy.tsx b/src/components/Question/components/Actions/BookmarkQuestion.cy.tsx index 58ff4fa..468e522 100644 --- a/src/components/Question/components/Actions/BookmarkQuestion.cy.tsx +++ b/src/components/Question/components/Actions/BookmarkQuestion.cy.tsx @@ -3,7 +3,7 @@ // Components import { Question } from "../../Question"; import { BookmarkQuestion } from "./BookmarkQuestion"; -import { ModuleProvider } from "../../../module/moduleContext"; +import { QuestionIdsProvider } from "../../../module/questionIdsContext"; import { CustomToastContainer } from "../../../toast/toast"; // Router @@ -25,8 +25,8 @@ declare const expect: Chai.ExpectStatic; function RenderBookmarkButtonWithRouter({ moduleID, questionID }: IParams) { return ( - - + +
@@ -34,7 +34,7 @@ function RenderBookmarkButtonWithRouter({ moduleID, questionID }: IParams) {
-
+
); } @@ -42,11 +42,11 @@ function RenderBookmarkButtonWithRouter({ moduleID, questionID }: IParams) { //Setup Router to access context and useParams const RenderQuestionWithRouter = ({ moduleID, questionID }: IParams) => { return ( - +
- + - +
diff --git a/src/components/Question/components/Actions/DeleteQuestion.cy.tsx b/src/components/Question/components/Actions/DeleteQuestion.cy.tsx index cd87ccf..43d289c 100644 --- a/src/components/Question/components/Actions/DeleteQuestion.cy.tsx +++ b/src/components/Question/components/Actions/DeleteQuestion.cy.tsx @@ -2,9 +2,12 @@ // Components import { Question } from "../../Question"; -import { ModuleProvider } from "../../../module/moduleContext"; +import { QuestionIdsProvider } from "../../../module/questionIdsContext"; import { CustomToastContainer } from "../../../toast/toast"; +// Functions +import { parseJSON } from "../../../../utils/parseJSON"; + // Router import { Route, MemoryRouter } from "react-router-dom"; @@ -15,21 +18,27 @@ import "../../../../index.css"; import { getBookmarkedLocalStorageItem } from "./BookmarkQuestion"; //Interfaces -import { IParams } from "../../../../utils/types"; +import { IParams, ISearchParams } from "../../../../utils/types"; +import { IModule } from "../../../module/module"; //Mocha for typescript declare var it: Mocha.TestFunction; declare var describe: Mocha.SuiteFunction; declare const expect: Chai.ExpectStatic; +interface IRenderWithRouter extends IParams { + mode: NonNullable; + order: NonNullable; +} + // Setup Router to access context and useParams -const RenderWithRouter = ({ moduleID, questionID }: IParams) => { +const RenderWithRouter: React.FC = ({ moduleID, questionID, mode, order }) => { return ( - +
- + - +
@@ -38,19 +47,159 @@ const RenderWithRouter = ({ moduleID, questionID }: IParams) => { describe("Delete a Question", () => { beforeEach(() => { - cy.viewport(500, 500); cy.fixtureToLocalStorage("repeatio-module-cypress_1.json"); - cy.mount(); }); - it("should delete a question and navigate to the next question", () => { - cy.get("button.show-question-nav").click(); - cy.get("button[aria-label='Delete Question']").click(); + it("should delete a question and navigate to the next question (mode=practice & order=chronological)", () => { + // Mount Component + cy.mount(); + + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button + cy.get("button[aria-label='Delete Question']") + .click() + .should(() => { + const module = parseJSON(localStorage.getItem(`repeatio-module-${"cypress_1"}`)); + expect(module?.questions.length).to.equal(5); + }); cy.contains("qID-2").should("exist"); }); + it("should delete a question and navigate to next question (mode=practice & order=random)", () => { + // Mount Component + cy.mount(); + + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button + cy.get("button[aria-label='Delete Question']") + .click() + .should(() => { + const module = parseJSON(localStorage.getItem(`repeatio-module-${"cypress_1"}`)); + expect(module?.questions.length).to.equal(5); + }); + cy.get(".question-id").should("exist"); + + // Navigate back + cy.get("button[aria-label='Navigate to previous Question'").click(); + cy.get(".question-id").should("exist"); + }); + + it("should delete Question navigate to the next question (mode=bookmarked & order=chronological)", () => { + // Mount Component + cy.mount(); + + //Setup localStorage + const localStorageItem = { + id: "cypress_1", + type: "marked", + compatibility: "0.4.0", + questions: ["qID-2", "qID-1"], + }; + + localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(localStorageItem, null, "\t")); + + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button + cy.get("button[aria-label='Delete Question']") + .click() + .should(() => { + /* Check that only one question was deleted from the localStorage */ + const module = parseJSON(localStorage.getItem(`repeatio-module-cypress_1`)); + expect(module?.questions.length).to.equal(5); + }); + + cy.contains("ID: qID-2").should("exist"); + }); + + it("should delete Question navigate to the next question (mode=bookmarked & order=chronological)", () => { + // Mount Component + cy.mount(); + + //Setup localStorage + const localStorageItem = { + id: "cypress_1", + type: "marked", + compatibility: "0.4.0", + questions: ["qID-2", "qID-1"], + }; + + localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(localStorageItem, null, "\t")); + + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button + cy.get("button[aria-label='Delete Question']") + .click() + .should(() => { + /* Check that only one question was deleted from the localStorage */ + const module = parseJSON(localStorage.getItem(`repeatio-module-${"cypress_1"}`)); + expect(module?.questions.length).to.equal(5); + }); + + cy.contains("ID: qID-2").should("exist"); + }); + + it("should delete Question navigate to the next question (mode=bookmarked & order=random)", () => { + // Mount Component + cy.mount(); + + //Setup localStorage + const localStorageItem = { + id: "cypress_1", + type: "marked", + compatibility: "0.4.0", + questions: ["qID-2", "qID-1"], + }; + + localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(localStorageItem, null, "\t")); + + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button + cy.get("button[aria-label='Delete Question']") + .click() + .should(() => { + /* Check that only one question was deleted from the localStorage */ + const module = parseJSON(localStorage.getItem(`repeatio-module-${"cypress_1"}`)); + expect(module?.questions.length).to.equal(5); + }); + + cy.contains("ID: qID-2").should("exist"); + }); + it("should remove question from bookmarked localStorage when deleting a question with the same id", () => { + // Mount Component + cy.mount(); + //Setup localStorage const localStorageItem = { id: "cypress_1", @@ -61,8 +210,14 @@ describe("Delete a Question", () => { localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(localStorageItem, null, "\t")); - //Delete question - cy.get("button.show-question-nav").click(); + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button cy.get("button[aria-label='Delete Question']") .click() .should(() => { @@ -73,6 +228,9 @@ describe("Delete a Question", () => { }); it("should remove the bookmarked localStorage when a question that is the last remaining bookmarked item", () => { + // Mount Component + cy.mount(); + //Setup localStorage const localStorageItem = { id: "cypress_1", @@ -83,8 +241,14 @@ describe("Delete a Question", () => { localStorage.setItem("repeatio-marked-cypress_1", JSON.stringify(localStorageItem, null, "\t")); - //Delete question - cy.get("button.show-question-nav").click(); + // Click show navigation button that only exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Click delete button cy.get("button[aria-label='Delete Question']") .click() .should(() => { @@ -92,4 +256,60 @@ describe("Delete a Question", () => { expect(localStorageItem).to.equal(null); }); }); + + it("should deselect the current selection if deleting a question", () => { + cy.fixtureToLocalStorage("repeatio-module-multiple_choice.json"); + cy.mount( + + ); + + // Select and submit question + cy.get("input[value='option-0']").click(); + + // Click show navigation button that just exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Delete Question + cy.get("button[aria-label='Delete Question']").click(); + + // Assert that the radio button is not selected and is enabled + cy.get("input[value='option-0']").should("not.be.selected").and("be.enabled"); + }); + + it("should hide the question correction after deleting a question", () => { + cy.fixtureToLocalStorage("repeatio-module-multiple_choice.json"); + cy.mount( + + ); + + // Select and submit question + cy.get("input[value='option-0']").click(); + + // Check answer to trigger correction to show up + cy.get("button[aria-label='Check Question']").click(); + + // Click show navigation button that just exists on small displays + cy.get("body").then((body) => { + if (body.find("button[aria-label='Show Navigation']").length > 0) { + cy.get("button[aria-label='Show Navigation']").click(); + } + }); + + // Delete Question + cy.get("button[aria-label='Delete Question']").click(); + + // Assert that the correction is no longer visible + cy.get("section.question-correction").should("not.exist"); + + // Assert that the radio button is not selected and is enabled + cy.get("input[value='option-0']").should("not.be.selected").and("be.enabled"); + }); }); + +/* //TODO + - Add test to allow user to delete the last question in the current context +*/ diff --git a/src/components/Question/components/Actions/DeleteQuestion.tsx b/src/components/Question/components/Actions/DeleteQuestion.tsx index 21e7963..53545d3 100644 --- a/src/components/Question/components/Actions/DeleteQuestion.tsx +++ b/src/components/Question/components/Actions/DeleteQuestion.tsx @@ -3,9 +3,10 @@ import { useParams, useHistory, useLocation } from "react-router-dom"; import isElectron from "is-electron"; import { toast } from "react-toastify"; import { getBookmarkedLocalStorageItem } from "./BookmarkQuestion"; +import { parseJSON } from "../../../../utils/parseJSON"; //Context -import { IModuleContext, ModuleContext } from "../../../module/moduleContext"; +import { IQuestionIdsContext, QuestionIdsContext } from "../../../module/questionIdsContext"; //TODO add moduleID as Component param not useParams @@ -14,15 +15,17 @@ import { BiTrash } from "react-icons/bi"; //Interfaces/Types import { IParams } from "../../../../utils/types"; -import { IQuestion } from "../../useQuestion"; +import { IQuestion, TUseQuestion } from "../../useQuestion"; +import { IModule } from "../../../module/module"; interface IDeleteQuestion extends React.DetailedHTMLProps, HTMLButtonElement> { questionID: IQuestion["id"] | undefined; disabled: boolean; + setShowAnswer: TUseQuestion["setShowAnswer"]; } -export const DeleteQuestion = ({ questionID, disabled, ...props }: IDeleteQuestion) => { +export const DeleteQuestion = ({ questionID, disabled, setShowAnswer, ...props }: IDeleteQuestion) => { //params const params = useParams(); @@ -33,7 +36,7 @@ export const DeleteQuestion = ({ questionID, disabled, ...props }: IDeleteQuesti let history = useHistory(); //Access Module - const { moduleData, setModuleData, filteredQuestions } = useContext(ModuleContext); + const { questionIds, setQuestionIds } = useContext(QuestionIdsContext); //Delete Question from storage const handleDelete = () => { @@ -43,63 +46,65 @@ export const DeleteQuestion = ({ questionID, disabled, ...props }: IDeleteQuesti return; } - //Don't allow to edit the basic module - if (moduleData.id === "types_1") { - toast.warn("Can't delete Questions of test module"); - return; - } - + //TODO allow this with history.push to module overview //Don't allow deletion on the last element in the module - if (moduleData.questions.length <= 1) { - //TODO fix this + if ((questionIds?.length || 0) <= 1) { toast.warn("Can't delete last question in module for now"); return; } - //Don't allow question editing when using mode random - if (new URLSearchParams(search).get("mode") === "random") { - //TODO fix this - toast.warn("Don't delete questions in mode random for this time."); - return; - } + // Remove question from localStorage + // Get module from localStorage + const module = parseJSON(localStorage.getItem(`repeatio-module-${params.moduleID}`)); - if (moduleData.questions.length !== filteredQuestions.length) { - toast.warn( - "Don't delete questions while viewing just saved questions. This causes too many bugs. I will try to fix this in the future. For now access this question not by using saved questions, but instead find the question with mode chronological or use the question overview to find this question." - ); + if (!module) { + toast.error("Couldn't find module!"); + return; } - //TODO Refactor the code below when the ModuleContext gets refactored - const indexInModuleQuestions = moduleData.questions.findIndex((question: IQuestion) => question.id === questionID); + // Get index of question that should be deleted + const indexInModuleQuestions = module.questions.findIndex((question: IQuestion) => question.id === questionID); //If question isn't in moduleData don't modify the storage. In Prod this error should never be shown! - if (indexInModuleQuestions <= -1) { + if (typeof indexInModuleQuestions === "undefined" || indexInModuleQuestions <= -1) { toast.error("Couldn't find questionID!"); return; } //Remove at given index one element - moduleData.questions.splice(indexInModuleQuestions, 1); + module.questions.splice(indexInModuleQuestions, 1); - //Update context - setModuleData({ ...moduleData, questions: moduleData.questions }); + // Update localStorage for module + localStorage.setItem(`repeatio-module-${params.moduleID}`, JSON.stringify(module, null, "\t")); - //Navigate to new path with new id - const indexInFilteredQuestions = filteredQuestions.findIndex((question: IQuestion) => question.id === questionID); + // Navigate to new path with new id + const indexInContextQuestionsIds = questionIds?.findIndex((id) => id === questionID); - //Because the Push to new url - if (indexInFilteredQuestions >= 1) { + // Hide show answer + setShowAnswer(false); + + if (typeof indexInContextQuestionsIds === "undefined") { + console.error("ID is not in data.questionIds"); + } else if (indexInContextQuestionsIds && indexInContextQuestionsIds >= 1) { + // Navigate to previous item in array if not at the beginning (0) history.push({ - pathname: `/module/${params.moduleID}/question/${filteredQuestions[indexInFilteredQuestions - 1].id}`, - search: `?mode=${new URLSearchParams(search).get("mode") || "chronological"}`, + pathname: `/module/${params.moduleID}/question/${questionIds?.[indexInContextQuestionsIds - 1]}`, + search: `?mode=${new URLSearchParams(search).get("mode") || "practice"}&order=${ + new URLSearchParams(search).get("order") || "chronological" + }`, }); } else { history.push({ - pathname: `/module/${params.moduleID}/question/${filteredQuestions[indexInFilteredQuestions + 1].id}`, - search: `?mode=${new URLSearchParams(search).get("mode") || "chronological"}`, + pathname: `/module/${params.moduleID}/question/${questionIds?.[indexInContextQuestionsIds + 1]}`, + search: `?mode=${new URLSearchParams(search).get("mode") || "practice"}&order=${ + new URLSearchParams(search).get("order") || "chronological" + }`, }); } + // Update questionIds context + setQuestionIds([...(questionIds ?? []).filter((id) => id !== questionID)]); + //Remove id from saved questions in localStorage //Get whole bookmarked item from localStorage const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem(params.moduleID); diff --git a/src/components/Question/components/Actions/EditQuestion.tsx b/src/components/Question/components/Actions/EditQuestion.tsx index 98bc112..d0eb12b 100644 --- a/src/components/Question/components/Actions/EditQuestion.tsx +++ b/src/components/Question/components/Actions/EditQuestion.tsx @@ -7,26 +7,37 @@ import { RiFileEditLine } from "react-icons/ri"; import { QuestionEditor } from "../../../QuestionEditor/QuestionEditor"; // Interfaces -import { IQuestion } from "../../useQuestion"; +import { IQuestion, TUseQuestion } from "../../useQuestion"; -interface EditQuestionI { - prevQuestionID: IQuestion["id"] | undefined; +interface IEditQuestion { + prevQuestion: IQuestion | undefined; disabled: boolean; + fetchQuestion: TUseQuestion["fetchQuestion"]; + setShowAnswer: TUseQuestion["setShowAnswer"]; } // Component -export const EditQuestion = ({ prevQuestionID, disabled }: EditQuestionI) => { +export const EditQuestion = ({ prevQuestion, disabled, fetchQuestion, setShowAnswer }: IEditQuestion) => { const [showModal, setShowModal] = useState(false); const handleModalClose = () => { setShowModal(false); }; + return ( <> - {showModal && } + {prevQuestion && showModal && ( + + )} ); }; diff --git a/src/components/Question/components/QuestionNavigation/QuestionNavigation.tsx b/src/components/Question/components/QuestionNavigation/QuestionNavigation.tsx index c6c5217..274ea6b 100644 --- a/src/components/Question/components/QuestionNavigation/QuestionNavigation.tsx +++ b/src/components/Question/components/QuestionNavigation/QuestionNavigation.tsx @@ -1,10 +1,13 @@ import React, { useRef, useContext } from "react"; import { useParams, useHistory, useLocation } from "react-router-dom"; import { toast } from "react-toastify"; -import { IParams } from "../../../../utils/types"; //Context -import { ModuleContext } from "../../../module/moduleContext"; +import { IQuestionIdsContext, QuestionIdsContext } from "../../../module/questionIdsContext"; + +//Interfaces +import { IParams } from "../../../../utils/types"; +import { TUseQuestion } from "../../useQuestion"; //Navigation svg from https://tablericons.com @@ -12,7 +15,11 @@ import { ModuleContext } from "../../../module/moduleContext"; //- Consider moving all Buttons into Components //- Remove to first and to last question -export const QuestionNavigation = () => { +type TQuestionNavigation = { + setShowAnswer: TUseQuestion["setShowAnswer"]; +}; + +export const QuestionNavigation: React.FC = ({ setShowAnswer }) => { //Access custom hook navigation functions const { navigateToFirstQuestion, @@ -30,7 +37,10 @@ export const QuestionNavigation = () => { + ); })} diff --git a/src/components/module/ModuleNotFound.css b/src/components/module/ModuleNotFound.css index 9e51132..54defe9 100644 --- a/src/components/module/ModuleNotFound.css +++ b/src/components/module/ModuleNotFound.css @@ -7,13 +7,13 @@ flex-direction: column; } -.module-not-found>*:not(h1) { +.module-not-found > *:not(h1) { margin-bottom: 1.5rem; } -.module-not-found>h1 { +.module-not-found > h1 { font-size: calc(600% + 10vw); - color: rgba(40, 42, 57, 0.12); + color: rgba(40, 42, 57, 0.2); line-height: 1.1; font-weight: 800; user-select: none; @@ -46,7 +46,7 @@ font-weight: 600; line-height: 1; - transition: box-shadow .2s; + transition: box-shadow 0.2s; } .module-not-found .navigation-links a:focus-visible, @@ -110,7 +110,7 @@ } @media screen and (max-width: 650px) { - .module-not-found>*:not(h1) { + .module-not-found > *:not(h1) { margin-bottom: 1rem; } @@ -123,4 +123,4 @@ margin-top: 1rem; min-width: auto; } -} \ No newline at end of file +} diff --git a/src/components/module/ModuleNotFound.tsx b/src/components/module/ModuleNotFound.tsx index 452195a..0ec58bb 100644 --- a/src/components/module/ModuleNotFound.tsx +++ b/src/components/module/ModuleNotFound.tsx @@ -90,7 +90,7 @@ export const UserModulesList = () => { {modules?.map(({ id, name }) => { return (
  • - + {name} ({id})
  • diff --git a/src/components/module/module.tsx b/src/components/module/module.tsx index 17dfd56..022148b 100644 --- a/src/components/module/module.tsx +++ b/src/components/module/module.tsx @@ -1,13 +1,11 @@ -import React, { useState, useContext, useCallback, useLayoutEffect, useEffect } from "react"; -import { useHistory, useParams } from "react-router-dom"; -import { ModuleContext } from "./moduleContext"; +import React, { useState, useCallback, useLayoutEffect, useEffect } from "react"; +import { useHistory, useParams, useLocation } from "react-router-dom"; import packageJSON from "../../../package.json"; //Components import { GridCards } from "../GridCards/GridCards"; import { SiteHeading } from "../SiteHeading/SiteHeading"; import { Card, LinkElement, ButtonElement } from "../Card/Card"; -import { Spinner } from "../Spinner/Spinner"; import { QuestionEditor } from "../QuestionEditor/QuestionEditor"; import { PopoverButton, PopoverMenu, PopoverMenuItem } from "../Card/Popover"; import { toast } from "react-toastify"; @@ -24,10 +22,10 @@ import { TbFileExport, TbFileImport } from "react-icons/tb"; //functions import { shuffleArray } from "../../utils/shuffleArray"; import { saveFile } from "../../utils/saveFile"; +import { parseJSON } from "../../utils/parseJSON"; //Interfaces and types import { IParams } from "../../utils/types.js"; -import { IModuleContext } from "./moduleContext"; import { getBookmarkedLocalStorageItem, getBookmarkedQuestionsFromModule, @@ -47,61 +45,46 @@ export interface IModule { questions: IQuestion[]; } +//TODO move this outside +interface LocationState { + name: string | undefined; +} + //Component export const Module = () => { + //Access state of link + const location = useLocation(); + //useState - const [module, setModule] = useState({}); - const [loading, setLoading] = useState(true); + //const [module, setModule] = useState({}); + const [moduleName, setModuleName] = useState(location.state?.name); const [error, setError] = useState(false); const [showModal, setShowModal] = useState(false); - //context - const { setFilteredQuestions, moduleData, setContextModuleID } = useContext(ModuleContext); - //History let history = useHistory(); //Params const { moduleID } = useParams<{ moduleID: string }>(); - /* USEEFFECTS */ - //Update the module state by using the data from the context - useLayoutEffect(() => { - //Context returned nothing because module wasn't found - if (moduleData === null) { - setError(true); - setLoading(false); - return; - } + /* Refetching the module name if the property is not passed by the router. This is the case when the user directly navigates to the module without navigating through the the home modules. */ + useEffect(() => { + if (!location.state?.name) { + // fetch from localStorage + const moduleNameFromStorage = parseJSON(localStorage.getItem(`repeatio-module-${moduleID}`))?.name; - if (Object.keys(moduleData)?.length === 0 || moduleData === undefined) { - setLoading(true); - setError(false); - return; + // if there is a module found with the id, update the moduleName state else show error + if (moduleNameFromStorage) { + setModuleName(moduleNameFromStorage); + } else { + setError(true); + } } - //Update module if module was found - setModule(moduleData); - setError(false); - setLoading(false); - return () => { - setModule({}); - setError(false); - setLoading(true); + setModuleName(""); }; - }, [moduleData]); - - //Tell the context to update with the new module (id is in the url) - useLayoutEffect(() => { - setContextModuleID(moduleID); - - return () => { - setModule({}); - setError(false); - setLoading(true); - }; - }, [moduleID, setContextModuleID]); + }, [moduleID, location.state?.name]); //TODO remove this with v0.5 useEffect(() => { @@ -125,34 +108,36 @@ export const Module = () => { }, [moduleID]); /*EVENTS*/ - //Train with all questions in chronological order + //Train with all questions in chronological order starting at the first question const onChronologicalClick = () => { - setFilteredQuestions(moduleData.questions); + let questions = parseJSON(localStorage.getItem(`repeatio-module-${moduleID}`))?.questions; - if (moduleData.questions !== undefined && moduleData.questions.length >= 1) { + // Navigate to first question if there are questions in the module else show warning + if (questions !== undefined && questions.length >= 1) { history.push({ - pathname: `/module/${moduleID}/question/${(module as IModule).questions[0].id}`, - search: "?mode=chronological", + pathname: `/module/${moduleID}/question/${questions[0].id}`, + search: "?mode=practice&order=chronological", }); } else { toast.warn("No questions found!"); - return; } }; //Train with all questions in random order const onRandomClick = () => { - //If the array isn't spread, it modifies the order of the original data - const shuffledQuestions = shuffleArray([...moduleData.questions]); - if (shuffledQuestions.length >= 1) { - setFilteredQuestions(shuffledQuestions); + const questions = parseJSON(localStorage.getItem(`repeatio-module-${moduleID}`))?.questions; + + if (questions && questions.length >= 1) { + //If the array isn't spread, it modifies the order of the original data + const shuffledQuestions = shuffleArray([...questions]); + + //setFilteredQuestions(shuffledQuestions); history.push({ pathname: `/module/${moduleID}/question/${shuffledQuestions[0].id}`, - search: "?mode=random", + search: "?mode=practice&order=random", }); } else { toast.warn("No questions found!"); - return; } }; @@ -231,23 +216,17 @@ export const Module = () => { }, ]; - //Show loading while module isn't set - if (loading) { - return ( -
    - -
    - ); - } - - if (error || Object.keys(module).length < 1) { + if (error) { return ; } + // TODO: Add Suspense with react 18 + // + //JSX return ( -
    - +
    + {moduleCards.map((card) => { const { title, disabled, description, icon, bottom } = card; @@ -266,7 +245,7 @@ export const Module = () => { ); })} - {showModal && } + {showModal && }
    ); }; @@ -281,9 +260,6 @@ const BookmarkedQuestionsBottom = () => { //Params const { moduleID } = useParams(); - //Context - const { setFilteredQuestions, moduleData } = useContext(ModuleContext); - //Reset anchor if component unmounts useLayoutEffect(() => { return () => { @@ -367,6 +343,9 @@ const BookmarkedQuestionsBottom = () => { let rejectedIDs: IBookmarkedQuestions["questions"] = []; let newIDs: IBookmarkedQuestions["questions"] = []; + //Get module from localStorage + const module = parseJSON(localStorage.getItem(`repeatio-module-${moduleID}`)); + //Get old saved questions from localStorage or provide empty array const bookmarkedLocalStorageItem = getBookmarkedLocalStorageItem(moduleID); @@ -376,7 +355,7 @@ const BookmarkedQuestionsBottom = () => { //Add only ids that are in the module (as question ids) and only if not already in localStorage importedBookmarkedFile?.questions?.forEach((importedID) => { //Return if id of the imported saved questions is not in the module - if (moduleData.questions?.findIndex((question: IQuestion) => question.id === importedID) === -1) { + if (module?.questions?.findIndex((question: IQuestion) => question.id === importedID) === -1) { rejectedIDs?.push(importedID); return false; } @@ -429,8 +408,6 @@ const BookmarkedQuestionsBottom = () => { //Train with only the saved Questions const onBookmarkedQuestionsClick = () => { - //TODO for electron get from filesystem - //Get the bookmarked ids from the localStorage item const bookmarkedQuestionsIDs = getBookmarkedQuestionsFromModule(moduleID); @@ -442,25 +419,27 @@ const BookmarkedQuestionsBottom = () => { return; } - //For each element in the bookmarked array return the question object - //kinda expensive calculation (array in array) :/ - let bookmarkedQuestions: IQuestion[] = []; - bookmarkedQuestionsIDs.forEach((item) => { - const question = moduleData.questions.find((question: IQuestion) => question.id === item); - //push question object to array if question is found - if (question !== undefined) { - bookmarkedQuestions.push(question); - } - }); + // Get an array of all question IDs from the module in local storage + const allIds = parseJSON(localStorage.getItem("repeatio-module-cypress_1"))?.questions.reduce( + (acc: string[], question) => { + acc.push(question.id); + return acc; + }, + [] + ); - //Update the context - setFilteredQuestions(bookmarkedQuestions); + // Find the first valid ID in bookmarkedQuestionsIDs that exists in allIds + const validId = bookmarkedQuestionsIDs.find((id) => allIds?.includes(id)); - //Navigate to question component - history.push({ - pathname: `/module/${moduleID}/question/${bookmarkedQuestions[0].id}`, - search: "?mode=chronological", - }); + if (validId) { + //Navigate to question component + history.push({ + pathname: `/module/${moduleID}/question/${validId}`, + search: "?mode=bookmarked&order=chronological", + }); + } else { + toast.error("Bookmarked Questions only include invalid ids! Please contact the developer on GitHub!"); + } }; return ( diff --git a/src/components/module/moduleContext.tsx b/src/components/module/moduleContext.tsx deleted file mode 100644 index 450f02d..0000000 --- a/src/components/module/moduleContext.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, { createContext, useMemo, useState, useEffect, useCallback } from "react"; -import { toast } from "react-toastify"; - -//Functions -import isElectron from "is-electron"; -import { fetchModuleFromPublicFolder } from "../../utils/fetchModuleFromPublicFolder"; -import { parseJSON } from "../../utils/parseJSON"; -import { IModule } from "./module"; - -export interface IModuleContext { - moduleData: IModule; - setModuleData: React.Dispatch>; - setContextModuleID: React.Dispatch>; - filteredQuestions: IModule["questions"]; - setFilteredQuestions: React.Dispatch>; -} - -//Create Question Context -export const ModuleContext = createContext({} as IModuleContext); - -//Provide the data to all children -export const ModuleProvider = ({ children }: { children: React.ReactNode }) => { - const [initialData, setInitialData] = useState({} as IModule); - const [filteredQuestions, setFilteredQuestions] = useState([]); - const [moduleContextID, setContextModuleID] = useState(""); - - //Change every time module name changes - const initialDataProvider = useMemo(() => ({ initialData, setInitialData }), [initialData, setInitialData]); - - const filterProvider = useMemo( - () => ({ filteredQuestions, setFilteredQuestions }), - [filteredQuestions, setFilteredQuestions] - ); - - //TODO move setFilteredQuestions/setInitialData into a callback - - //Get the module data from the localStorage of the browser - const getDataFromBrowser = useCallback(async () => { - let module; - - if (moduleContextID !== "types_1") { - //Fetch data from the locale Storage - try { - //module = JSON.parse(localStorage.getItem(`repeatio-module-${moduleContextID}`)); - module = parseJSON(localStorage.getItem(`repeatio-module-${moduleContextID}`)); - } catch (error) { - if (error instanceof Error) { - toast.warn(error.message); - } - } - } else { - //Fetch data from public folder - module = await fetchModuleFromPublicFolder(); - } - - //Set the data - setInitialData(module); - setFilteredQuestions(module?.questions); - }, [moduleContextID]); - - //Get all Questions from the file system / locale storage and provide them - useEffect(() => { - if (moduleContextID === "") return; - - //Get the data from the locale file system when using the electron application else (when using the website) get the data from the public folder/browser storage - if (isElectron()) { - // Send a message to the main process - (window as any).api.request("toMain", ["getModule", moduleContextID]); - - // Called when message received from main process - (window as any).api.response("fromMain", (data: IModule) => { - setInitialData(data); - setFilteredQuestions(data.questions); - }); - } else { - //Not using electron - getDataFromBrowser(); - } - - //Cleanup - return () => { - setInitialData({} as IModule); - setFilteredQuestions([]); - }; - }, [moduleContextID, getDataFromBrowser]); - - //Update the localStorage/filesystem if initialData changes - useEffect(() => { - //Don't update the storage if the data is undefined or from the public folder (id: types_1) - if ( - !initialData || - initialData === undefined || - Object.keys(initialData)?.length < 1 || - initialData?.id === "types_1" - ) { - return; - } - - //Update filesystem (electron) or localStorage (website) - if (isElectron()) { - //TODO save to filesystem - } else if (!isElectron()) { - try { - localStorage.setItem(`repeatio-module-${initialData.id}`, JSON.stringify(initialData, null, "\t")); - } catch (error) { - if (error instanceof Error) { - toast.warn(error.message); - } - } - } - }, [initialData]); - - return ( - - {children} - - ); -}; diff --git a/src/components/module/questionIdsContext.tsx b/src/components/module/questionIdsContext.tsx new file mode 100644 index 0000000..4928c8f --- /dev/null +++ b/src/components/module/questionIdsContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useMemo, useState } from "react"; + +// Interfaces +import { IQuestion } from "../Question/useQuestion"; + +export interface IQuestionIdsContext { + questionIds: IQuestion["id"][]; + setQuestionIds: React.Dispatch>; +} + +//Create Context +export const QuestionIdsContext = createContext({} as IQuestionIdsContext); + +//Provide the data to all children +export const QuestionIdsProvider = ({ children }: { children: React.ReactNode }) => { + const [data, setData] = useState([]); + + // Memorize the data + const dataProvider = useMemo(() => ({ data, setData }), [data, setData]); + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 7a74625..9989453 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,7 +29,7 @@ import { Footer } from "./components/Footer/Footer"; import { CustomToastContainer } from "./components/toast/toast"; //Context -import { ModuleProvider } from "./components/module/moduleContext"; +import { QuestionIdsProvider } from "./components/module/questionIdsContext"; //Import functions import { ScrollToTop } from "./utils/ScrollToTop"; @@ -52,11 +52,11 @@ ReactDOM.render( - - + + + - - +