From b2a6952cc018c14c1729aeaabe470929355b5009 Mon Sep 17 00:00:00 2001 From: Dan Halson <danhalson@users.noreply.github.com> Date: Tue, 30 May 2023 15:50:38 +0100 Subject: [PATCH] Add eslintrc (#502) closes #510 Adds basic eslintrc using prettier Adds editorconfig --- .devcontainer/devcontainer.json | 14 +- .editorconfig | 18 + .eslintrc.json | 47 + CHANGELOG.md | 2 + package.json | 13 +- src/App.test.js | 162 +-- src/Icons.js | 4 +- src/app/ComponentStore.js | 6 +- src/app/store.js | 21 +- src/components/AppRoutes.js | 72 +- .../AstroPiControls/AstroPiControls.js | 71 +- .../AstroPiControls/AstroPiControls.test.js | 87 +- .../AstroPiModel/AstroPiControls/Input.js | 34 +- .../AstroPiControls/Input.test.js | 34 +- .../AstroPiControls/MotionInput.js | 70 +- .../AstroPiControls/MotionInput.test.js | 186 ++-- .../AstroPiControls/SliderInput.js | 43 +- .../AstroPiControls/SliderInput.test.js | 39 +- .../AstroPiModel/AstroPiControls/Stopwatch.js | 82 +- .../AstroPiControls/Stopwatch.test.js | 34 +- src/components/AstroPiModel/AstroPiModel.js | 114 ++- .../AstroPiModel/AstroPiModel.test.js | 61 +- .../AstroPiModel/DefaultMZCriteria.js | 4 +- src/components/AstroPiModel/FlightCase.js | 76 +- .../AstroPiModel/FlightCase.test.js | 8 +- src/components/AstroPiModel/Lighting.js | 53 +- src/components/AstroPiModel/Lighting.test.js | 6 +- .../OrientationPanel/OrientationPanel.js | 36 +- .../OrientationPanel/OrientationPanel.test.js | 21 +- .../OrientationPanel/OrientationReading.js | 18 +- .../OrientationReading.test.js | 14 +- .../OrientationResetButton.js | 19 +- .../OrientationResetButton.test.js | 16 +- src/components/AstroPiModel/Simulator.js | 74 +- src/components/AstroPiModel/Simulator.test.js | 36 +- src/components/BetaBanner/BetaBanner.js | 67 +- src/components/BetaBanner/BetaBanner.test.js | 57 +- src/components/Callback.js | 24 +- .../Editor/DraggableTabs/DraggableTab.js | 62 +- .../Editor/DraggableTabs/DraggableTab.test.js | 88 +- .../Editor/DraggableTabs/DroppableTabList.js | 30 +- .../DraggableTabs/DroppableTabList.test.js | 14 +- src/components/Editor/Editor.js | 25 +- .../Editor/EditorInput/EditorInput.js | 179 ++-- .../Editor/EditorInput/EditorInput.test.js | 143 +-- .../Editor/EditorPanel/EditorPanel.js | 89 +- .../Editor/EditorPanel/EditorPanel.test.js | 63 +- src/components/Editor/EditorSlice.js | 58 +- src/components/Editor/EditorSlice.test.js | 928 ++++++++++-------- .../Editor/ErrorMessage/ErrorMessage.js | 12 +- .../Editor/ErrorMessage/ErrorMessage.test.js | 49 +- .../Editor/ErrorMessage/NameErrorMessage.js | 8 +- .../FontSizeSelector/FontSizeSelector.js | 71 +- .../FontSizeSelector/FontSizeSelector.test.js | 58 +- .../Editor/Hooks/useEmbeddedMode.js | 6 +- src/components/Editor/Hooks/useProject.js | 4 +- .../Editor/Hooks/useProject.test.js | 6 +- .../Editor/Hooks/useRequiresUser.js | 13 +- .../ImageUploadButton/ImageUploadButton.js | 158 +-- .../ImageUploadButton.test.js | 58 +- .../NewComponentButton/NewComponentButton.js | 44 +- .../NewComponentButton.test.js | 50 +- .../NewInputPanelButton.js | 18 +- src/components/Editor/Output/Output.js | 14 +- src/components/Editor/Output/Output.test.js | 34 +- src/components/Editor/Project/Project.js | 213 ++-- src/components/Editor/Project/Project.test.js | 739 ++++++++------ .../ProjectComponentLoader.test.js | 77 +- .../Editor/Runners/HtmlRunner/HtmlRunner.js | 18 +- .../Runners/HtmlRunner/HtmlRunner.test.js | 18 +- .../Runners/PythonRunner/OutputViewToggle.js | 56 +- .../PythonRunner/OutputViewToggle.test.js | 296 +++--- .../Runners/PythonRunner/PythonRunner.js | 443 +++++---- .../Runners/PythonRunner/PythonRunner.test.js | 832 +++++++++------- .../Runners/PythonRunner/VisualOutputPane.js | 82 +- .../PythonRunner/VisualOutputPane.test.js | 115 ++- .../Editor/Runners/RunnerFactory.js | 17 +- src/components/Editor/editorDarkTheme.js | 79 +- src/components/Editor/editorLightTheme.js | 47 +- .../EmbeddedControls/EmbeddedControls.js | 16 +- .../EmbeddedViewer/EmbeddedViewer.test.js | 31 +- src/components/ExternalFiles/ExternalFiles.js | 29 +- .../ExternalFiles/ExternalFiles.test.js | 46 +- src/components/Footer/Footer.js | 44 +- src/components/Footer/Footer.test.js | 52 +- src/components/GlobalNav/GlobalNav.js | 33 +- src/components/GlobalNav/GlobalNav.test.js | 76 +- src/components/Header/Autosave.js | 57 +- src/components/Header/DownloadButton.js | 50 +- src/components/Header/DownloadButton.test.js | 177 ++-- src/components/Header/Header.js | 146 +-- src/components/Header/Header.test.js | 339 ++++--- src/components/Header/ProjectName.js | 91 +- src/components/Header/ProjectName.test.js | 154 +-- .../LocaleLayout/LocaleLayout.test.js | 6 +- src/components/Login/LoginButton.js | 32 +- src/components/Login/LoginButton.test.js | 188 ++-- src/components/Login/LoginMenu.js | 43 +- src/components/Login/LoginMenu.test.js | 113 ++- src/components/Login/LogoutButton.js | 32 +- src/components/Login/LogoutButton.test.js | 60 +- .../Menus/ContextMenu/ContextMenu.js | 75 +- .../Menus/ContextMenu/ContextMenu.test.js | 67 +- src/components/Menus/Dropdown/Dropdown.js | 50 +- .../Menus/Dropdown/Dropdown.test.js | 57 +- src/components/Menus/FileMenu/FileMenu.js | 42 +- .../Menus/FileMenu/FileMenu.test.js | 74 +- .../ProjectActionsMenu/ProjectActionsMenu.js | 47 +- .../ProjectActionsMenu.test.js | 66 +- .../Menus/SettingsMenu/SettingsMenu.js | 25 +- .../Menus/SettingsMenu/SettingsMenu.test.js | 12 +- .../Menus/SideMenu/FilePane/FilePane.js | 29 +- .../Menus/SideMenu/FilePane/FilePane.test.js | 84 +- .../Menus/SideMenu/FilePane/FilesList.js | 55 +- .../Menus/SideMenu/FilePane/FilesList.test.js | 223 +++-- .../FilePane/ProjectImages/ProjectImages.js | 34 +- .../ProjectImages/ProjectImages.test.js | 52 +- src/components/Menus/SideMenu/MenuSideBar.js | 66 +- .../Menus/SideMenu/MenuSideBar.test.js | 24 +- .../Menus/SideMenu/MenuSideBarOption.js | 30 +- .../Menus/SideMenu/MenuSidebarOption.test.js | 24 +- src/components/Menus/SideMenu/SideMenu.js | 79 +- .../Menus/SideMenu/SideMenu.test.js | 44 +- .../Modals/AccessDeniedNoAuthModal.js | 45 +- .../Modals/AccessDeniedNoAuthModal.test.js | 66 +- .../Modals/AccessDeniedWithAuthModal.js | 51 +- .../Modals/AccessDeniedWithAuthModal.test.js | 84 +- src/components/Modals/BetaModal.js | 26 +- src/components/Modals/BetaModal.test.js | 54 +- src/components/Modals/DeleteProjectModal.js | 46 +- .../Modals/DeleteProjectModal.test.js | 99 +- src/components/Modals/ErrorModal.test.js | 14 +- src/components/Modals/GeneralModal.js | 79 +- src/components/Modals/GeneralModal.test.js | 50 +- src/components/Modals/InputModal.js | 34 +- src/components/Modals/InputModal.test.js | 48 +- src/components/Modals/LoginToSaveModal.js | 43 +- .../Modals/LoginToSaveModal.test.js | 56 +- src/components/Modals/NewFileModal.js | 75 +- src/components/Modals/NewFileModal.test.js | 99 +- src/components/Modals/NotFoundModal.js | 45 +- src/components/Modals/NotFoundModal.test.js | 116 ++- src/components/Modals/RenameFile.js | 77 +- src/components/Modals/RenameFile.test.js | 112 ++- src/components/Modals/RenameProjectModal.js | 51 +- .../Modals/RenameProjectModal.test.js | 155 +-- src/components/ProjectIndex/ProjectIndex.js | 6 +- .../ProjectIndex/ProjectIndex.test.js | 4 +- .../ProjectIndexPagination.test.js | 24 +- .../ProjectIndexHeader/ProjectIndexHeader.js | 25 +- .../ProjectListItem/ProjectListItem.js | 2 +- .../ProjectListItem/ProjectListItem.test.js | 56 +- .../ProjectListTable/ProjectListTable.js | 36 +- .../ProjectListTable/ProjectListTable.test.js | 68 +- src/components/ProjectViewer/ProjectViewer.js | 25 +- src/components/RunButton/RunBar.js | 10 +- src/components/RunButton/RunBar.test.js | 28 +- src/components/RunButton/RunButton.js | 21 +- src/components/RunButton/RunButton.test.js | 22 +- src/components/RunButton/RunnerControls.js | 35 +- .../RunButton/RunnerControls.test.js | 58 +- src/components/RunButton/StopButton.js | 39 +- src/components/RunButton/StopButton.test.js | 66 +- src/components/SilentRenew.js | 12 +- src/components/ThemeToggle/ThemeToggle.js | 63 +- .../ThemeToggle/ThemeToggle.test.js | 108 +- .../Project/WebComponentProject.js | 91 +- .../WebComponentLoader/WebComponentLoader.js | 28 +- .../WebComponent/WebComponentSlice.js | 18 +- src/hooks/useUserFont.js | 12 +- src/i18n.js | 13 +- src/index.js | 61 +- src/reportWebVitals.js | 4 +- src/sentry.js | 17 +- src/settings.js | 12 +- src/utils/Geometry.js | 131 ++- src/utils/Notifications.js | 59 +- src/utils/Notifications.test.js | 61 +- src/utils/Orientation.js | 63 +- src/utils/ResizableWithHandle.js | 65 +- src/utils/ResizableWithHandle.test.js | 36 +- src/utils/ToastCloseButton.js | 11 +- src/utils/ToastCloseButton.test.js | 22 +- src/utils/apiCallHandler.js | 97 +- src/utils/apiCallHandler.test.js | 220 +++-- src/utils/apolloCache.js | 4 +- src/utils/componentNameValidation.js | 70 +- src/utils/containerQueries.js | 4 +- src/utils/defaultProjects.js | 37 +- src/utils/login.js | 27 +- src/utils/projectHelpers.js | 8 +- src/utils/projectHelpers.test.js | 106 +- src/web-component.js | 34 +- yarn.lock | 29 + 194 files changed, 7910 insertions(+), 5794 deletions(-) create mode 100644 .editorconfig create mode 100644 .eslintrc.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f863e6244..69cf95aa5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -64,10 +64,20 @@ "github.remotehub", "circleci.circleci", "stylelint.vscode-stylelint", - "Orta.vscode-jest" + "Orta.vscode-jest", + "christian-kohler.path-intellisense", + "esbenp.prettier-vscode", + "avraammavridis.vsc-react-documentation", + "ofhumanbondage.react-proptypes-intellisense", + "syler.sass-indented", + "codezombiech.gitignore" ], "settings": { - "terminal.integrated.defaultProfile.linux": "zsh" + "terminal.integrated.defaultProfile.linux": "zsh", + "jest.autoRun": { + "watch": false, + "onSave": "test-src-file" + } } } }, diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..d7638731d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true + +[*.md] +max_line_length = 0 +trim_trailing_whitespace = false + +[COMMIT_EDITMSG] +max_line_length = 0 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..530cfa5e8 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,47 @@ +{ + "root": true, + "extends": [ + "react-app", + "react-app/jest", + "plugin:prettier/recommended" + ], + "plugins": [ + "prettier", + "react", + "jsx-a11y", + "jest" + ], + "settings": { + "react": { + "version": "18.1.0" + } + }, + "parser": "babel-eslint", + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "modules": true, + "jsx": true + } + }, + "env": { + "amd": true, + "browser": true, + "es6": true, + "jquery": false, + "node": true + }, + "rules": { + "prettier/prettier": [ + "error", + { + "singleQuote": false, + "jsxSingleQuote": false, + "trailingComma": "all", + "semi": true, + "bracketSpacing": true, + "arrowParens": "always" + } + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f479d28d5..ffa0376ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added ### Changed +- Move eslint config to `.eslintrc.json` with prettier support +- Add `.editorconfig` ### Fixed diff --git a/package.json b/package.json index d9707aa92..f15d75b8e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "codemirror": "^6.0.1", "container-query-polyfill": "^1.0.2", "date-fns": "^2.29.3", + "eslint-config-prettier": "^8.8.0", "file-saver": "^2.0.5", "fs-extra": "^9.0.1", "graphql": "^16.6.0", @@ -76,7 +77,9 @@ "build": "node scripts/build.js", "build:dev": "yarn run build-storybook && node scripts/build.js", "build-storybook": "cd ./storybook && yarn install && yarn run build-storybook -- -o ../public/storybook --loglevel warn", - "lint": "eslint src/. --ext .js", + "lint": "eslint src/**/*.{js,jsx,json}", + "lint:fix": "eslint --fix src/**/*.{js,jsx,json}", + "format": "prettier --write src/**/*.{js,jsx,css,md,json,scss} --config ./.prettierrc", "stylelint": "stylelint --syntax=scss src/**/*.scss", "test": "node scripts/test.js --transformIgnorePatterns 'node_modules/(?!three)/'", "test:integration": "cd e2e; docker compose up --exit-code-from cypress", @@ -86,12 +89,6 @@ "build:wc": "NODE_ENV=production BABEL_ENV=production webpack build -c ./webpack.component.config.js", "heroku-postbuild": "export PUBLIC_URL='' && yarn build && yarn build:wc" }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, "browserslist": { "production": [ ">0.2%", @@ -135,6 +132,7 @@ "eslint-plugin-import": "^2.22.1", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.21.5", "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-testing-library": "^3.9.2", @@ -160,6 +158,7 @@ "postcss-normalize": "8.0.1", "postcss-preset-env": "6.7.0", "postcss-safe-parser": "5.0.2", + "prettier": "^2.8.8", "react-dev-utils": "^11.0.3", "react-test-renderer": "^17.0.2", "redux-mock-store": "^1.5.4", diff --git a/src/App.test.js b/src/App.test.js index 9283d3432..2794faf73 100644 --- a/src/App.test.js +++ b/src/App.test.js @@ -1,21 +1,21 @@ -import App from './App'; -import { Provider } from 'react-redux' -import React from 'react'; -import { act, render, screen } from '@testing-library/react'; -import { Cookies, CookiesProvider } from 'react-cookie'; -import configureStore from 'redux-mock-store'; - -jest.mock('./utils/Notifications') -jest.mock('./components/Editor/EditorSlice', () => { - const actual = jest.requireActual('./components/Editor/EditorSlice') +import App from "./App"; +import { Provider } from "react-redux"; +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import { Cookies, CookiesProvider } from "react-cookie"; +import configureStore from "redux-mock-store"; + +jest.mock("./utils/Notifications"); +jest.mock("./components/Editor/EditorSlice", () => { + const actual = jest.requireActual("./components/Editor/EditorSlice"); return { ...actual, - saveProject: jest.fn() - } -}) + saveProject: jest.fn(), + }; +}); -describe('Browser prefers light mode', () => { - let store +describe("Browser prefers light mode", () => { + let store; let cookies; beforeEach(() => { @@ -28,49 +28,49 @@ describe('Browser prefers light mode', () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), - }) + }); - cookies = new Cookies() - const middlewares = [] - const mockStore = configureStore(middlewares) + cookies = new Cookies(); + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: {}, - auth: {} - } + auth: {}, + }; store = mockStore(initialState); - }) + }); - test('Light mode class name added if no cookie', () => { + test("Light mode class name added if no cookie", () => { const appContainer = render( <CookiesProvider cookies={cookies}> <Provider store={store}> <App /> </Provider> - </CookiesProvider> - ) - expect(appContainer.container.querySelector('#app')).toHaveClass("--light") - }) + </CookiesProvider>, + ); + expect(appContainer.container.querySelector("#app")).toHaveClass("--light"); + }); - test('Dark mode class name added if cookie specifies dark theme', () => { - cookies.set('theme', 'dark') + test("Dark mode class name added if cookie specifies dark theme", () => { + cookies.set("theme", "dark"); const appContainer = render( <CookiesProvider cookies={cookies}> <Provider store={store}> <App /> </Provider> - </CookiesProvider> - ) - expect(appContainer.container.querySelector('#app')).toHaveClass("--dark") - }) + </CookiesProvider>, + ); + expect(appContainer.container.querySelector("#app")).toHaveClass("--dark"); + }); afterEach(() => { - act(() => cookies.remove('theme')) - }) -}) + act(() => cookies.remove("theme")); + }); +}); -describe('Browser prefers dark mode', () => { +describe("Browser prefers dark mode", () => { let cookies; - let store + let store; beforeEach(() => { window.matchMedia = (query) => ({ @@ -82,84 +82,84 @@ describe('Browser prefers dark mode', () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), - }) + }); cookies = new Cookies(); - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: {}, - auth: {} - } + auth: {}, + }; store = mockStore(initialState); - }) + }); - test('Dark mode class name added if no cookie', () => { + test("Dark mode class name added if no cookie", () => { const appContainer = render( <CookiesProvider cookies={cookies}> <Provider store={store}> <App /> </Provider> - </CookiesProvider> - ) - expect(appContainer.container.querySelector('#app')).toHaveClass("--dark") - }) + </CookiesProvider>, + ); + expect(appContainer.container.querySelector("#app")).toHaveClass("--dark"); + }); - test('Light mode class name added if cookie specifies light theme', () => { - cookies.set('theme', 'light') + test("Light mode class name added if cookie specifies light theme", () => { + cookies.set("theme", "light"); const appContainer = render( <CookiesProvider cookies={cookies}> <Provider store={store}> <App /> </Provider> - </CookiesProvider> - ) - expect(appContainer.container.querySelector('#app')).toHaveClass("--light") - }) + </CookiesProvider>, + ); + expect(appContainer.container.querySelector("#app")).toHaveClass("--light"); + }); afterEach(() => { - act(() => cookies.remove('theme')) - }) -}) + act(() => cookies.remove("theme")); + }); +}); -describe('Beta banner', () => { - let cookies - let store +describe("Beta banner", () => { + let cookies; + let store; beforeEach(() => { - cookies = new Cookies() - const middlewares = [] - const mockStore = configureStore(middlewares) + cookies = new Cookies(); + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: {}, - auth: {} - } + auth: {}, + }; store = mockStore(initialState); - }) + }); - test('Renders beta banner if betaBannerDismissed cookie not set', () => { + test("Renders beta banner if betaBannerDismissed cookie not set", () => { render( <CookiesProvider cookies={cookies}> <Provider store={store}> <App /> </Provider> - </CookiesProvider> - ) - expect(screen.queryByText('betaBanner.message')).toBeInTheDocument() - }) + </CookiesProvider>, + ); + expect(screen.queryByText("betaBanner.message")).toBeInTheDocument(); + }); - test('Does not render beta banner if betaBannerDismissed cookie is true', () => { - cookies.set('betaBannerDismissed', 'true') + test("Does not render beta banner if betaBannerDismissed cookie is true", () => { + cookies.set("betaBannerDismissed", "true"); render( <CookiesProvider cookies={cookies}> <Provider store={store}> <App /> </Provider> - </CookiesProvider> - ) - expect(screen.queryByText('betaBanner.message')).not.toBeInTheDocument() - }) + </CookiesProvider>, + ); + expect(screen.queryByText("betaBanner.message")).not.toBeInTheDocument(); + }); afterEach(() => { - act(() => cookies.remove('betaBannerDismissed')) - }) -}) + act(() => cookies.remove("betaBannerDismissed")); + }); +}); diff --git a/src/Icons.js b/src/Icons.js index 7279eaf6c..d22681f41 100644 --- a/src/Icons.js +++ b/src/Icons.js @@ -432,7 +432,7 @@ export const RunIcon = () => { fill="none" xmlns="http://www.w3.org/2000/svg" > - <path d="M0 14V0L11 7L0 14Z"/> + <path d="M0 14V0L11 7L0 14Z" /> </svg> ); }; @@ -480,7 +480,7 @@ export const StopIcon = () => { fill="none" xmlns="http://www.w3.org/2000/svg" > - <path d="M0 12V0H12V12H0Z"/> + <path d="M0 12V0H12V12H0Z" /> </svg> ); }; diff --git a/src/app/ComponentStore.js b/src/app/ComponentStore.js index d7c8ed823..f6bd2325f 100644 --- a/src/app/ComponentStore.js +++ b/src/app/ComponentStore.js @@ -1,10 +1,10 @@ -import { configureStore } from '@reduxjs/toolkit' -import EditorReducer from '../components/Editor/EditorSlice' +import { configureStore } from "@reduxjs/toolkit"; +import EditorReducer from "../components/Editor/EditorSlice"; const ComponentStore = configureStore({ reducer: { editor: EditorReducer, }, -}) +}); export default ComponentStore; diff --git a/src/app/store.js b/src/app/store.js index d91ee01f4..55cffeec9 100644 --- a/src/app/store.js +++ b/src/app/store.js @@ -1,7 +1,7 @@ -import { configureStore } from '@reduxjs/toolkit' -import EditorReducer from '../components/Editor/EditorSlice' -import { reducer, loadUser } from 'redux-oidc' -import userManager from '../utils/userManager' +import { configureStore } from "@reduxjs/toolkit"; +import EditorReducer from "../components/Editor/EditorSlice"; +import { reducer, loadUser } from "redux-oidc"; +import userManager from "../utils/userManager"; const store = configureStore({ reducer: { @@ -11,11 +11,14 @@ const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { - ignoredActions: ['redux-oidc/USER_FOUND', 'redux-odic/SILENT_RENEW_ERROR'], - ignoredPaths: ['auth.user'], - }, - }), -}) + ignoredActions: [ + "redux-oidc/USER_FOUND", + "redux-odic/SILENT_RENEW_ERROR", + ], + ignoredPaths: ["auth.user"], + }, + }), +}); loadUser(store, userManager); diff --git a/src/components/AppRoutes.js b/src/components/AppRoutes.js index 2c41de761..dfa671a85 100644 --- a/src/components/AppRoutes.js +++ b/src/components/AppRoutes.js @@ -1,40 +1,40 @@ -import { React } from 'react' -import { Route, Routes, Navigate, useParams } from 'react-router-dom' +import { React } from "react"; +import { Route, Routes, Navigate, useParams } from "react-router-dom"; import * as Sentry from "@sentry/react"; -import ProjectComponentLoader from './Editor/ProjectComponentLoader/ProjectComponentLoader' -import ProjectIndex from './ProjectIndex/ProjectIndex' -import EmbeddedViewer from './EmbeddedViewer/EmbeddedViewer' -import Callback from './Callback' -import SilentRenew from './SilentRenew' -import LocaleLayout from './LocaleLayout/LocaleLayout'; +import ProjectComponentLoader from "./Editor/ProjectComponentLoader/ProjectComponentLoader"; +import ProjectIndex from "./ProjectIndex/ProjectIndex"; +import EmbeddedViewer from "./EmbeddedViewer/EmbeddedViewer"; +import Callback from "./Callback"; +import SilentRenew from "./SilentRenew"; +import LocaleLayout from "./LocaleLayout/LocaleLayout"; -const projectLinkRedirects = ['/null/projects/:identifier', '/projects/:identifier'] -const localeRedirects = ['/', '/projects'] +const projectLinkRedirects = [ + "/null/projects/:identifier", + "/projects/:identifier", +]; +const localeRedirects = ["/", "/projects"]; const ProjectsRedirect = () => { const { identifier } = useParams(); - return <Navigate replace to={`/en/projects/${identifier}`} /> -} + return <Navigate replace to={`/en/projects/${identifier}`} />; +}; -const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes) +const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); const AppRoutes = () => ( <SentryRoutes> - <Route - path="/auth/callback" - element={<Callback/>} - /> + <Route path="/auth/callback" element={<Callback />} /> - <Route - path="/auth/silent_renew" - element={<SilentRenew/>} - /> - <Route path={":locale"} element={<LocaleLayout/>}> + <Route path="/auth/silent_renew" element={<SilentRenew />} /> + <Route path={":locale"} element={<LocaleLayout />}> <Route index element={<ProjectComponentLoader />} /> <Route path={"projects"} element={<ProjectIndex />} /> - <Route path={"projects/:identifier"} element={<ProjectComponentLoader />} /> - <Route path="embed/viewer/:identifier" element={<EmbeddedViewer/>} /> + <Route + path={"projects/:identifier"} + element={<ProjectComponentLoader />} + /> + <Route path="embed/viewer/:identifier" element={<EmbeddedViewer />} /> </Route> <Route @@ -44,14 +44,20 @@ const AppRoutes = () => ( {/* Redirects will be moved into a cloudflare worker. This is just interim */} - { projectLinkRedirects.map(link => { - return <Route key={link} path={link} element={<ProjectsRedirect />} /> - }) } - - { localeRedirects.map(link => { - return <Route key={link} path={link} element={<Navigate replace to={`/en${link}`} />} /> - }) } + {projectLinkRedirects.map((link) => { + return <Route key={link} path={link} element={<ProjectsRedirect />} />; + })} + + {localeRedirects.map((link) => { + return ( + <Route + key={link} + path={link} + element={<Navigate replace to={`/en${link}`} />} + /> + ); + })} </SentryRoutes> -) +); -export default AppRoutes +export default AppRoutes; diff --git a/src/components/AstroPiModel/AstroPiControls/AstroPiControls.js b/src/components/AstroPiModel/AstroPiControls/AstroPiControls.js index 02dff4224..3ca189fc4 100644 --- a/src/components/AstroPiModel/AstroPiControls/AstroPiControls.js +++ b/src/components/AstroPiModel/AstroPiControls/AstroPiControls.js @@ -1,34 +1,65 @@ -import React from 'react'; -import Input from './Input'; -import MotionInput from './MotionInput'; -import SliderInput from './SliderInput'; -import '../AstroPiModel.scss'; -import Stopwatch from './Stopwatch'; -import { HumidityIcon, PressureIcon, TemperatureIcon } from '../../../Icons'; -import { useTranslation } from 'react-i18next'; +import React from "react"; +import Input from "./Input"; +import MotionInput from "./MotionInput"; +import SliderInput from "./SliderInput"; +import "../AstroPiModel.scss"; +import Stopwatch from "./Stopwatch"; +import { HumidityIcon, PressureIcon, TemperatureIcon } from "../../../Icons"; +import { useTranslation } from "react-i18next"; const AstroPiControls = (props) => { - const {temperature, pressure, humidity, colour, motion} = props - const { t } = useTranslation() + const { temperature, pressure, humidity, colour, motion } = props; + const { t } = useTranslation(); return ( - <div className='sense-hat-controls'> - <h2 className='sense-hat-controls-heading'>{t('output.senseHat.controls.name')}</h2> + <div className="sense-hat-controls"> + <h2 className="sense-hat-controls-heading"> + {t("output.senseHat.controls.name")} + </h2> <div className="sense-hat-controls-panel"> - <div className='sense-hat-controls-panel__sliders'> - <SliderInput name="temperature" label={t('output.senseHat.controls.temperature')} unit="°C" min={-40} max={120} defaultValue={temperature} Icon={TemperatureIcon} /> - <SliderInput name="pressure" label={t('output.senseHat.controls.pressure')} unit="hPa" min={260} max={1260} defaultValue={pressure} Icon={PressureIcon}/> - <SliderInput name="humidity" label={t('output.senseHat.controls.humidity')} unit="%" min={0} max={100} defaultValue={humidity} Icon={HumidityIcon}/> + <div className="sense-hat-controls-panel__sliders"> + <SliderInput + name="temperature" + label={t("output.senseHat.controls.temperature")} + unit="°C" + min={-40} + max={120} + defaultValue={temperature} + Icon={TemperatureIcon} + /> + <SliderInput + name="pressure" + label={t("output.senseHat.controls.pressure")} + unit="hPa" + min={260} + max={1260} + defaultValue={pressure} + Icon={PressureIcon} + /> + <SliderInput + name="humidity" + label={t("output.senseHat.controls.humidity")} + unit="%" + min={0} + max={100} + defaultValue={humidity} + Icon={HumidityIcon} + /> </div> - + <div className="sense-hat-controls-panel__control sense-hat-controls-panel__control-last"> - <Input name="colour" label={t('output.senseHat.controls.colour')} type="color" defaultValue={colour} /> + <Input + name="colour" + label={t("output.senseHat.controls.colour")} + type="color" + defaultValue={colour} + /> <MotionInput defaultValue={motion} /> <Stopwatch /> </div> </div> </div> - ) + ); }; -export default AstroPiControls +export default AstroPiControls; diff --git a/src/components/AstroPiModel/AstroPiControls/AstroPiControls.test.js b/src/components/AstroPiModel/AstroPiControls/AstroPiControls.test.js index 12c23ea6c..36a4e94c0 100644 --- a/src/components/AstroPiModel/AstroPiControls/AstroPiControls.test.js +++ b/src/components/AstroPiModel/AstroPiControls/AstroPiControls.test.js @@ -1,64 +1,73 @@ import React from "react"; -import { render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import AstroPiControls from "./AstroPiControls"; -import Sk from 'skulpt'; +import Sk from "skulpt"; let container; let store; beforeEach(() => { - Sk.sense_hat={ + Sk.sense_hat = { mz_criteria: {}, rtimu: { - temperature: [0,0], - pressure: [0,0], - humidity: [0,0] - } - } - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - codeRunTriggered: false - }, - } - store = mockStore(initialState); - container = render(<Provider store={store}><AstroPiControls temperature={65} pressure={456} humidity={37} motion={true} colour="#e01010"/></Provider>) -}) + temperature: [0, 0], + pressure: [0, 0], + humidity: [0, 0], + }, + }; + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + codeRunTriggered: false, + }, + }; + store = mockStore(initialState); + container = render( + <Provider store={store}> + <AstroPiControls + temperature={65} + pressure={456} + humidity={37} + motion={true} + colour="#e01010" + /> + </Provider>, + ); +}); test("Renders temperature with units", () => { - expect(container.queryByText("65°C")).not.toBeNull() -}) + expect(container.queryByText("65°C")).not.toBeNull(); +}); test("Renders pressure with units", () => { - expect(container.getByText("456hPa")).not.toBeNull() -}) + expect(container.getByText("456hPa")).not.toBeNull(); +}); test("Renders humidity with units", () => { - expect(container.getByText("37%")).not.toBeNull() -}) + expect(container.getByText("37%")).not.toBeNull(); +}); test("Renders temperature input with correct value", () => { - expect(container.queryByDisplayValue(65)).not.toBeNull() -}) + expect(container.queryByDisplayValue(65)).not.toBeNull(); +}); test("Renders pressure input with correct value", () => { - expect(container.queryByDisplayValue(456)).not.toBeNull() -}) + expect(container.queryByDisplayValue(456)).not.toBeNull(); +}); test("Renders humidity input with correct value", () => { - expect(container.queryByDisplayValue(37)).not.toBeNull() -}) + expect(container.queryByDisplayValue(37)).not.toBeNull(); +}); test("Renders motion input with correct value", () => { - expect(container.queryByLabelText('output.senseHat.controls.motion').checked).toBe(true) -}) + expect( + container.queryByLabelText("output.senseHat.controls.motion").checked, + ).toBe(true); +}); test("Renders colour input with correct value", () => { - expect(container.queryByDisplayValue("#e01010")).not.toBeNull() -}) - - - + expect(container.queryByDisplayValue("#e01010")).not.toBeNull(); +}); diff --git a/src/components/AstroPiModel/AstroPiControls/Input.js b/src/components/AstroPiModel/AstroPiControls/Input.js index f8b7ec9a2..39b448452 100644 --- a/src/components/AstroPiModel/AstroPiControls/Input.js +++ b/src/components/AstroPiModel/AstroPiControls/Input.js @@ -1,24 +1,34 @@ -import React from 'react'; -import { useEffect, useState } from 'react'; -import Sk from 'skulpt'; -import '../AstroPiModel.scss'; +import React from "react"; +import { useEffect, useState } from "react"; +import Sk from "skulpt"; +import "../AstroPiModel.scss"; const Input = (props) => { - const { name, label, type, defaultValue} = props; + const { name, label, type, defaultValue } = props; const [value, setValue] = useState(defaultValue); useEffect(() => { if (Sk.sense_hat) { - Sk.sense_hat[name] = value + Sk.sense_hat[name] = value; } - }, [name, value]) + }, [name, value]); return ( - <div className='sense-hat-controls-panel__container'> - <label className='sense-hat-controls-panel__control-name' htmlFor={`sense_hat_${name}`}>{label}</label> - <input type={type} id={`sense_hat_${name}`} defaultValue={value} onChange={e => setValue(e.target.value)} /> + <div className="sense-hat-controls-panel__container"> + <label + className="sense-hat-controls-panel__control-name" + htmlFor={`sense_hat_${name}`} + > + {label} + </label> + <input + type={type} + id={`sense_hat_${name}`} + defaultValue={value} + onChange={(e) => setValue(e.target.value)} + /> </div> - ) + ); }; -export default Input +export default Input; diff --git a/src/components/AstroPiModel/AstroPiControls/Input.test.js b/src/components/AstroPiModel/AstroPiControls/Input.test.js index 01eec23e1..318d4d233 100644 --- a/src/components/AstroPiModel/AstroPiControls/Input.test.js +++ b/src/components/AstroPiModel/AstroPiControls/Input.test.js @@ -1,27 +1,35 @@ import React from "react"; -import { render } from "@testing-library/react" +import { render } from "@testing-library/react"; import Input from "./Input"; -import Sk from 'skulpt'; +import Sk from "skulpt"; let inputComponent; beforeEach(() => { - Sk.sense_hat={} - inputComponent = render(<Input name="foo" label="bar" type="color" defaultValue="#e01010"/>) -}) + Sk.sense_hat = {}; + inputComponent = render( + <Input name="foo" label="bar" type="color" defaultValue="#e01010" />, + ); +}); test("Renders as expected", () => { - expect(inputComponent.queryByText(/bar/)).not.toBeNull() -}) + expect(inputComponent.queryByText(/bar/)).not.toBeNull(); +}); test("Creates input of correct type", () => { - expect(inputComponent.queryByLabelText(/bar/)).toHaveAttribute("type", "color") -}) + expect(inputComponent.queryByLabelText(/bar/)).toHaveAttribute( + "type", + "color", + ); +}); test("Creates input with correct value", () => { - expect(inputComponent.queryByLabelText(/bar/)).toHaveAttribute("value", "#e01010") -}) + expect(inputComponent.queryByLabelText(/bar/)).toHaveAttribute( + "value", + "#e01010", + ); +}); test("Sets skulpt value correctly", () => { - expect(Sk.sense_hat.foo).toEqual("#e01010") -}) + expect(Sk.sense_hat.foo).toEqual("#e01010"); +}); diff --git a/src/components/AstroPiModel/AstroPiControls/MotionInput.js b/src/components/AstroPiModel/AstroPiControls/MotionInput.js index cddec8972..eb5e5ba3c 100644 --- a/src/components/AstroPiModel/AstroPiControls/MotionInput.js +++ b/src/components/AstroPiModel/AstroPiControls/MotionInput.js @@ -1,42 +1,60 @@ -import React from 'react'; -import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import Toggle from 'react-toggle'; -import Sk from 'skulpt'; -import '../AstroPiModel.scss'; -import "react-toggle/style.css" -import { useTranslation } from 'react-i18next'; +import React from "react"; +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import Toggle from "react-toggle"; +import Sk from "skulpt"; +import "../AstroPiModel.scss"; +import "react-toggle/style.css"; +import { useTranslation } from "react-i18next"; const MotionInput = (props) => { - const {defaultValue} = props; + const { defaultValue } = props; const [value, setValue] = useState(defaultValue); - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered); - const { t } = useTranslation() + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); + const { t } = useTranslation(); useEffect(() => { if (!codeRunTriggered) { - Sk.sense_hat.start_motion_callback = () => {} - Sk.sense_hat.stop_motion_callback = () => {} + Sk.sense_hat.start_motion_callback = () => {}; + Sk.sense_hat.stop_motion_callback = () => {}; } - }, [codeRunTriggered]) + }, [codeRunTriggered]); useEffect(() => { if (Sk.sense_hat) { - Sk.sense_hat.motion = value + Sk.sense_hat.motion = value; } - value ? Sk.sense_hat.start_motion_callback() : Sk.sense_hat.stop_motion_callback() - }, [value]) + value + ? Sk.sense_hat.start_motion_callback() + : Sk.sense_hat.stop_motion_callback(); + }, [value]); return ( - <div className="sense-hat-controls-panel__container"> - <label className='sense-hat-controls-panel__control-name' htmlFor={`sense_hat_motion`}>{t('output.senseHat.controls.motion')}</label> - <div className='sense-hat-controls-panel__control-toggle'> - <label htmlFor={`sense_hat_motion`} >{t('output.senseHat.controls.motionSensorOptions.no')}</label> - <Toggle id='sense_hat_motion' icons={false} checked={value} onChange={e => setValue(e.target.checked)}/> - <label htmlFor={`sense_hat_motion`} >{t('output.senseHat.controls.motionSensorOptions.yes')}</label> + <div className="sense-hat-controls-panel__container"> + <label + className="sense-hat-controls-panel__control-name" + htmlFor={`sense_hat_motion`} + > + {t("output.senseHat.controls.motion")} + </label> + <div className="sense-hat-controls-panel__control-toggle"> + <label htmlFor={`sense_hat_motion`}> + {t("output.senseHat.controls.motionSensorOptions.no")} + </label> + <Toggle + id="sense_hat_motion" + icons={false} + checked={value} + onChange={(e) => setValue(e.target.checked)} + /> + <label htmlFor={`sense_hat_motion`}> + {t("output.senseHat.controls.motionSensorOptions.yes")} + </label> + </div> </div> - </div> - ) + ); }; -export default MotionInput +export default MotionInput; diff --git a/src/components/AstroPiModel/AstroPiControls/MotionInput.test.js b/src/components/AstroPiModel/AstroPiControls/MotionInput.test.js index c41cf64f6..61f154f13 100644 --- a/src/components/AstroPiModel/AstroPiControls/MotionInput.test.js +++ b/src/components/AstroPiModel/AstroPiControls/MotionInput.test.js @@ -1,115 +1,135 @@ import React from "react"; -import { render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import MotionInput from "./MotionInput"; -import Sk from 'skulpt'; +import Sk from "skulpt"; let container; let store; -const start_motion_function = jest.fn() -const stop_motion_function = jest.fn() +const start_motion_function = jest.fn(); +const stop_motion_function = jest.fn(); describe("No motion and code running", () => { beforeEach(() => { - Sk.sense_hat={ + Sk.sense_hat = { start_motion_callback: start_motion_function, - stop_motion_callback: stop_motion_function - } - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - codeRunTriggered: true - }, - } - store = mockStore(initialState); - container = render(<Provider store={store}><MotionInput defaultValue={false}/></Provider>) - }) - + stop_motion_callback: stop_motion_function, + }; + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + codeRunTriggered: true, + }, + }; + store = mockStore(initialState); + container = render( + <Provider store={store}> + <MotionInput defaultValue={false} /> + </Provider>, + ); + }); + test("Displays checkbox with correct value", () => { - expect(container.queryByLabelText('output.senseHat.controls.motion').checked).toBe(false) - }) - + expect( + container.queryByLabelText("output.senseHat.controls.motion").checked, + ).toBe(false); + }); + test("Stop motion function has been called", () => { - expect(stop_motion_function).toHaveBeenCalledTimes(1) - }) - + expect(stop_motion_function).toHaveBeenCalledTimes(1); + }); + test("Skulpt sense hat motion set to false", () => { - expect(Sk.sense_hat.motion).toBe(false) - }) -}) + expect(Sk.sense_hat.motion).toBe(false); + }); +}); describe("Motion and code running", () => { beforeEach(() => { - Sk.sense_hat={ + Sk.sense_hat = { start_motion_callback: start_motion_function, - stop_motion_callback: stop_motion_function - } - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - codeRunTriggered: true - }, - } - store = mockStore(initialState); - container = render(<Provider store={store}><MotionInput defaultValue={true}/></Provider>) - }) - + stop_motion_callback: stop_motion_function, + }; + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + codeRunTriggered: true, + }, + }; + store = mockStore(initialState); + container = render( + <Provider store={store}> + <MotionInput defaultValue={true} /> + </Provider>, + ); + }); + test("Displays checkbox with correct value", () => { - expect(container.queryByLabelText('output.senseHat.controls.motion').checked).toBe(true) - }) - + expect( + container.queryByLabelText("output.senseHat.controls.motion").checked, + ).toBe(true); + }); + test("Stop motion function has been called", () => { - expect(start_motion_function).toHaveBeenCalledTimes(1) - }) + expect(start_motion_function).toHaveBeenCalledTimes(1); + }); test("Skulpt sense hat motion set to true", () => { - expect(Sk.sense_hat.motion).toBe(true) - }) -}) + expect(Sk.sense_hat.motion).toBe(true); + }); +}); describe("No motion and code not running", () => { beforeEach(() => { - Sk.sense_hat={ + Sk.sense_hat = { start_motion_callback: start_motion_function, - stop_motion_callback: stop_motion_function - } - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - codeRunTriggered: false - }, - } - store = mockStore(initialState); - container = render(<Provider store={store}><MotionInput defaultValue={false}/></Provider>) - }) + stop_motion_callback: stop_motion_function, + }; + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + codeRunTriggered: false, + }, + }; + store = mockStore(initialState); + container = render( + <Provider store={store}> + <MotionInput defaultValue={false} /> + </Provider>, + ); + }); test("Stop motion function not called", () => { - expect(stop_motion_function).not.toHaveBeenCalled() - }) -}) + expect(stop_motion_function).not.toHaveBeenCalled(); + }); +}); describe("Motion and code not running", () => { beforeEach(() => { - Sk.sense_hat={ + Sk.sense_hat = { start_motion_callback: start_motion_function, - stop_motion_callback: stop_motion_function - } - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - codeRunTriggered: false - }, - } - store = mockStore(initialState); - container = render(<Provider store={store}><MotionInput defaultValue={true}/></Provider>) - }) + stop_motion_callback: stop_motion_function, + }; + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + codeRunTriggered: false, + }, + }; + store = mockStore(initialState); + container = render( + <Provider store={store}> + <MotionInput defaultValue={true} /> + </Provider>, + ); + }); test("Start motion function not called", () => { - expect(start_motion_function).not.toHaveBeenCalled() - }) -}) + expect(start_motion_function).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/AstroPiModel/AstroPiControls/SliderInput.js b/src/components/AstroPiModel/AstroPiControls/SliderInput.js index 8444184d5..03277016f 100644 --- a/src/components/AstroPiModel/AstroPiControls/SliderInput.js +++ b/src/components/AstroPiModel/AstroPiControls/SliderInput.js @@ -1,28 +1,45 @@ -import React from 'react'; -import { useEffect, useState } from 'react'; -import '../AstroPiModel.scss'; -import Sk from 'skulpt'; +import React from "react"; +import { useEffect, useState } from "react"; +import "../AstroPiModel.scss"; +import Sk from "skulpt"; const SliderInput = (props) => { - const { name, label, unit, min, max, defaultValue, Icon} = props; + const { name, label, unit, min, max, defaultValue, Icon } = props; const [value, setValue] = useState(defaultValue); - + useEffect(() => { if (Sk.sense_hat) { - Sk.sense_hat.rtimu[name][1] = value+Math.random()-0.5 + Sk.sense_hat.rtimu[name][1] = value + Math.random() - 0.5; } - }, [name, value]) + }, [name, value]); return ( <div className="sense-hat-controls-panel__control"> - <label className='sense-hat-controls-panel__control-name' htmlFor={`sense_hat_${name}`}>{label}</label> - <input id={`sense_hat_${name}`} className="sense-hat-controls-panel__control-input" type="range" min={min} max={max} step="1" defaultValue={value} onChange={e => setValue(parseFloat(e.target.value))}/> + <label + className="sense-hat-controls-panel__control-name" + htmlFor={`sense_hat_${name}`} + > + {label} + </label> + <input + id={`sense_hat_${name}`} + className="sense-hat-controls-panel__control-input" + type="range" + min={min} + max={max} + step="1" + defaultValue={value} + onChange={(e) => setValue(parseFloat(e.target.value))} + /> <div className="sense-hat-controls-panel__control-reading"> {Icon ? <Icon /> : null} - <span className='sense-hat-controls-panel__control-value'>{value}{unit}</span> + <span className="sense-hat-controls-panel__control-value"> + {value} + {unit} + </span> </div> </div> - ) + ); }; -export default SliderInput +export default SliderInput; diff --git a/src/components/AstroPiModel/AstroPiControls/SliderInput.test.js b/src/components/AstroPiModel/AstroPiControls/SliderInput.test.js index 7f1cd1163..3924bbf9e 100644 --- a/src/components/AstroPiModel/AstroPiControls/SliderInput.test.js +++ b/src/components/AstroPiModel/AstroPiControls/SliderInput.test.js @@ -1,29 +1,36 @@ import React from "react"; -import { render } from "@testing-library/react" +import { render } from "@testing-library/react"; import SliderInput from "./SliderInput"; -import Sk from 'skulpt'; +import Sk from "skulpt"; let container; -Sk.sense_hat={ +Sk.sense_hat = { rtimu: { - "foo": [0,0] - } -} + foo: [0, 0], + }, +}; beforeEach(() => { - container = render(<SliderInput name="foo" unit="bar" max={2000} min={0} defaultValue={1234}/>) - -}) + container = render( + <SliderInput + name="foo" + unit="bar" + max={2000} + min={0} + defaultValue={1234} + />, + ); +}); test("Renders with correct unit and value", () => { - expect(container.queryByText("1234bar")).not.toBeNull() -}) + expect(container.queryByText("1234bar")).not.toBeNull(); +}); test("Renders slider input with correct value", () => { - expect(container.queryByDisplayValue(1234)).not.toBeNull() -}) + expect(container.queryByDisplayValue(1234)).not.toBeNull(); +}); test("Correct skulpt value when renders", () => { - expect(Sk.sense_hat.rtimu["foo"][1]).toBeLessThan(1234.5) - expect(Sk.sense_hat.rtimu["foo"][1]).toBeGreaterThanOrEqual(1233.5) -}) + expect(Sk.sense_hat.rtimu["foo"][1]).toBeLessThan(1234.5); + expect(Sk.sense_hat.rtimu["foo"][1]).toBeGreaterThanOrEqual(1233.5); +}); diff --git a/src/components/AstroPiModel/AstroPiControls/Stopwatch.js b/src/components/AstroPiModel/AstroPiControls/Stopwatch.js index d4f99a8d9..c36191bb0 100644 --- a/src/components/AstroPiModel/AstroPiControls/Stopwatch.js +++ b/src/components/AstroPiModel/AstroPiControls/Stopwatch.js @@ -1,47 +1,63 @@ -import React, { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; -import { useStopwatch } from 'react-timer-hook'; -import Sk from 'skulpt' +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import { useStopwatch } from "react-timer-hook"; +import Sk from "skulpt"; const Stopwatch = () => { - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered) - const { - seconds, - minutes, - isRunning, - pause, - reset - } = useStopwatch({ autoStart: false }) - const [hasLostFocus, setHasLostFocus] = useState(false) - const { t } = useTranslation() + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); + const { seconds, minutes, isRunning, pause, reset } = useStopwatch({ + autoStart: false, + }); + const [hasLostFocus, setHasLostFocus] = useState(false); + const { t } = useTranslation(); useEffect(() => { - window.addEventListener('blur', () => { - setHasLostFocus(true) - }) - }, []) + window.addEventListener("blur", () => { + setHasLostFocus(true); + }); + }, []); useEffect(() => { if (codeRunTriggered && !isRunning) { - setHasLostFocus(false) - reset() + setHasLostFocus(false); + reset(); } - if (!codeRunTriggered && isRunning){ - pause() - Sk.sense_hat.mz_criteria.duration = hasLostFocus ? null : minutes * 60 + seconds + if (!codeRunTriggered && isRunning) { + pause(); + Sk.sense_hat.mz_criteria.duration = hasLostFocus + ? null + : minutes * 60 + seconds; } - }, [codeRunTriggered, hasLostFocus, minutes, seconds, isRunning, pause, reset]) - + }, [ + codeRunTriggered, + hasLostFocus, + minutes, + seconds, + isRunning, + pause, + reset, + ]); return ( - <div className='sense-hat-controls-panel__container sense-hat-controls-panel__container-timer'> - <label className='sense-hat-controls-panel__control-name' htmlFor='sense_hat_timer'>{t('output.senseHat.controls.timer')}</label> - <span className='sense-hat-controls-panel__control-reading sense-hat-controls-panel__control-reading-timer' id='sense_hat_timer'> - <span>{String(minutes).padStart(2, '0')}</span>:<span>{String(seconds).padStart(2, '0')}</span> + <div className="sense-hat-controls-panel__container sense-hat-controls-panel__container-timer"> + <label + className="sense-hat-controls-panel__control-name" + htmlFor="sense_hat_timer" + > + {t("output.senseHat.controls.timer")} + </label> + <span + className="sense-hat-controls-panel__control-reading sense-hat-controls-panel__control-reading-timer" + id="sense_hat_timer" + > + <span>{String(minutes).padStart(2, "0")}</span>: + <span>{String(seconds).padStart(2, "0")}</span> </span> </div> - ) -} + ); +}; -export default Stopwatch +export default Stopwatch; diff --git a/src/components/AstroPiModel/AstroPiControls/Stopwatch.test.js b/src/components/AstroPiModel/AstroPiControls/Stopwatch.test.js index af9ecf7d5..63cc0b13b 100644 --- a/src/components/AstroPiModel/AstroPiControls/Stopwatch.test.js +++ b/src/components/AstroPiModel/AstroPiControls/Stopwatch.test.js @@ -1,23 +1,27 @@ import React from "react"; -import { render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import Sk from 'skulpt'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import Sk from "skulpt"; import Stopwatch from "./Stopwatch"; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); const initialState = { editor: { - codeRunTriggered: false + codeRunTriggered: false, }, -} +}; const store = mockStore(initialState); -Sk.sense_hat={ - mz_criteria: {} -} +Sk.sense_hat = { + mz_criteria: {}, +}; -test("Stopwatch renders in form mm:ss", () =>{ - const { getAllByText } = render(<Provider store = {store}><Stopwatch /></Provider>) - expect(getAllByText("00")).toHaveLength(2) -}) +test("Stopwatch renders in form mm:ss", () => { + const { getAllByText } = render( + <Provider store={store}> + <Stopwatch /> + </Provider>, + ); + expect(getAllByText("00")).toHaveLength(2); +}); diff --git a/src/components/AstroPiModel/AstroPiModel.js b/src/components/AstroPiModel/AstroPiModel.js index 956ca669f..f14de8729 100644 --- a/src/components/AstroPiModel/AstroPiModel.js +++ b/src/components/AstroPiModel/AstroPiModel.js @@ -1,77 +1,97 @@ -import React from 'react'; -import './AstroPiModel.scss'; -import Simulator from './Simulator'; -import Sk from 'skulpt'; -import AstroPiControls from './AstroPiControls/AstroPiControls'; -import OrientationPanel from './OrientationPanel/OrientationPanel'; -import { useEffect, useState } from 'react'; -import { resetModel, updateRTIMU } from '../../utils/Orientation'; -import { useSelector } from 'react-redux'; -import { defaultMZCriteria } from './DefaultMZCriteria'; +import React from "react"; +import "./AstroPiModel.scss"; +import Simulator from "./Simulator"; +import Sk from "skulpt"; +import AstroPiControls from "./AstroPiControls/AstroPiControls"; +import OrientationPanel from "./OrientationPanel/OrientationPanel"; +import { useEffect, useState } from "react"; +import { resetModel, updateRTIMU } from "../../utils/Orientation"; +import { useSelector } from "react-redux"; +import { defaultMZCriteria } from "./DefaultMZCriteria"; const AstroPiModel = () => { - const project = useSelector((state) => state.editor.project) - const [orientation, setOrientation] = useState([0,90,0]) + const project = useSelector((state) => state.editor.project); + const [orientation, setOrientation] = useState([0, 90, 0]); const resetOrientation = (e) => { - resetModel(e) - setOrientation([0,90,0]) - } + resetModel(e); + setOrientation([0, 90, 0]); + }; - const defaultPressure = 1013 - const defaultTemperature = 13 - const defaultHumidity = 45 + const defaultPressure = 1013; + const defaultTemperature = 13; + const defaultHumidity = 45; - if (!Sk.sense_hat){ + if (!Sk.sense_hat) { Sk.sense_hat = { colour: "#FF00A4", - gamma: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + gamma: [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + ], low_light: false, motion: false, - mz_criteria: {...defaultMZCriteria}, + mz_criteria: { ...defaultMZCriteria }, pixels: [], rtimu: { - pressure: [1, defaultPressure+Math.random()-0.5], /* isValid, pressure*/ - temperature: [1, defaultTemperature+Math.random()-0.5], /* isValid, temperature */ - humidity: [1, defaultHumidity+Math.random()-0.5], /* isValid, humidity */ - gyro: [0, 0, 0], /* all 3 gyro values */ - accel: [0, 0, 0], /* all 3 accel values */ - compass: [0, 0, 33], /* all compass values */ - raw_orientation: [0, 90, 0] + pressure: [ + 1, + defaultPressure + Math.random() - 0.5, + ] /* isValid, pressure*/, + temperature: [ + 1, + defaultTemperature + Math.random() - 0.5, + ] /* isValid, temperature */, + humidity: [ + 1, + defaultHumidity + Math.random() - 0.5, + ] /* isValid, humidity */, + gyro: [0, 0, 0] /* all 3 gyro values */, + accel: [0, 0, 0] /* all 3 accel values */, + compass: [0, 0, 33] /* all compass values */, + raw_orientation: [0, 90, 0], }, sensestick: { _eventQueue: [], off: () => {}, - once: () => {} + once: () => {}, }, start_motion_callback: () => {}, stop_motion_callback: () => {}, - } + }; for (var i = 0; i < 64; i++) { Sk.sense_hat.pixels.push([0, 0, 0]); } } useEffect(() => { - Sk.sense_hat.mz_criteria = {...defaultMZCriteria} + Sk.sense_hat.mz_criteria = { ...defaultMZCriteria }; }, [project]); useEffect(() => { - Sk.sense_hat.rtimu.raw_orientation = orientation - updateRTIMU() - }, [orientation]) + Sk.sense_hat.rtimu.raw_orientation = orientation; + updateRTIMU(); + }, [orientation]); - return ( - <div className='sense-hat'> - <div className='sense-hat-model'> - <Simulator updateOrientation={setOrientation}/> - <OrientationPanel orientation={orientation} resetOrientation={resetOrientation}/> - </div> + return ( + <div className="sense-hat"> + <div className="sense-hat-model"> + <Simulator updateOrientation={setOrientation} /> + <OrientationPanel + orientation={orientation} + resetOrientation={resetOrientation} + /> + </div> - {/* <!-- Full sensor controls --> */} - <AstroPiControls pressure={defaultPressure} temperature={defaultTemperature} humidity={defaultHumidity} colour={Sk.sense_hat.colour} motion={Sk.sense_hat.motion} /> + {/* <!-- Full sensor controls --> */} + <AstroPiControls + pressure={defaultPressure} + temperature={defaultTemperature} + humidity={defaultHumidity} + colour={Sk.sense_hat.colour} + motion={Sk.sense_hat.motion} + /> + </div> + ); +}; - </div> - ) - }; - - export default AstroPiModel; +export default AstroPiModel; diff --git a/src/components/AstroPiModel/AstroPiModel.test.js b/src/components/AstroPiModel/AstroPiModel.test.js index d3c36df7e..8ca5e791b 100644 --- a/src/components/AstroPiModel/AstroPiModel.test.js +++ b/src/components/AstroPiModel/AstroPiModel.test.js @@ -1,41 +1,46 @@ import React from "react"; -import { fireEvent, render } from "@testing-library/react" -import { Provider } from 'react-redux'; +import { fireEvent, render } from "@testing-library/react"; +import { Provider } from "react-redux"; import AstroPiModel from "./AstroPiModel"; -import configureStore from 'redux-mock-store'; -import Sk from 'skulpt'; +import configureStore from "redux-mock-store"; +import Sk from "skulpt"; let container; let store; beforeEach(() => { - Sk.sense_hat={ + Sk.sense_hat = { mz_criteria: {}, rtimu: { - temperature: [0,0], - pressure: [0,0], - humidity: [0,0] - } - } - window.mod={ - rotation: {} - } - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - codeRunTriggered: false - }, - } - store = mockStore(initialState); - container = render(<Provider store={store}> <AstroPiModel/> </Provider>) -}) + temperature: [0, 0], + pressure: [0, 0], + humidity: [0, 0], + }, + }; + window.mod = { + rotation: {}, + }; + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + codeRunTriggered: false, + }, + }; + store = mockStore(initialState); + container = render( + <Provider store={store}> + {" "} + <AstroPiModel />{" "} + </Provider>, + ); +}); test("Component renders", () => { - expect(container).not.toBeNull() -}) + expect(container).not.toBeNull(); +}); test("Update orientation function resets the model", () => { - fireEvent.click(container.getByRole("button")) - expect(window.mod.rotation).toStrictEqual({x: 0, y: 0, z:0}) -}) + fireEvent.click(container.getByRole("button")); + expect(window.mod.rotation).toStrictEqual({ x: 0, y: 0, z: 0 }); +}); diff --git a/src/components/AstroPiModel/DefaultMZCriteria.js b/src/components/AstroPiModel/DefaultMZCriteria.js index 96d355881..d8f0b47f4 100644 --- a/src/components/AstroPiModel/DefaultMZCriteria.js +++ b/src/components/AstroPiModel/DefaultMZCriteria.js @@ -5,5 +5,5 @@ export const defaultMZCriteria = { readHumidity: false, readPressure: false, readTemperature: false, - usedLEDs: false -} + usedLEDs: false, +}; diff --git a/src/components/AstroPiModel/FlightCase.js b/src/components/AstroPiModel/FlightCase.js index 160ce76aa..b229e2d69 100644 --- a/src/components/AstroPiModel/FlightCase.js +++ b/src/components/AstroPiModel/FlightCase.js @@ -1,73 +1,77 @@ -import React from 'react'; -import * as THREE from 'three'; -import { useGLTF } from '@react-three/drei'; +import React from "react"; +import * as THREE from "three"; +import { useGLTF } from "@react-three/drei"; import Sk from "skulpt"; -import { invalidate } from '@react-three/fiber'; +import { invalidate } from "@react-three/fiber"; const FlightCase = () => { + const { scene } = useGLTF( + `${process.env.PUBLIC_URL}/models/raspi-compressed.glb`, + ); + window.mod = scene; - const { scene } = useGLTF(`${process.env.PUBLIC_URL}/models/raspi-compressed.glb`) - window.mod = scene - - const blankLED = new THREE.MeshStandardMaterial({color: `rgb(0,0,0)`}) + const blankLED = new THREE.MeshStandardMaterial({ color: `rgb(0,0,0)` }); var leds = { - '0_0_0': blankLED - } + "0_0_0": blankLED, + }; - function setPixel(ledIndex,r,g,b) { + function setPixel(ledIndex, r, g, b) { var x = ledIndex % 8; var y = Math.floor(ledIndex / 8); - let ledMaterial + let ledMaterial; if (`${r}_${g}_${b}` in leds) { - ledMaterial = leds[`${r}_${g}_${b}`] + ledMaterial = leds[`${r}_${g}_${b}`]; } else { - ledMaterial = new THREE.MeshStandardMaterial({color: `rgb(${r},${g},${b})`}); - leds[`${r}_${g}_${b}`] = ledMaterial + ledMaterial = new THREE.MeshStandardMaterial({ + color: `rgb(${r},${g},${b})`, + }); + leds[`${r}_${g}_${b}`] = ledMaterial; } - var object = scene.getObjectByName(`circle${x}_${7-y}-1`); + var object = scene.getObjectByName(`circle${x}_${7 - y}-1`); - if(object != null){ + if (object != null) { object.material = ledMaterial; } } function setPixels(indexes, pix) { - if(indexes == null){ - indexes = Array.from(Array(8*8).keys()) + if (indexes == null) { + indexes = Array.from(Array(8 * 8).keys()); } var i = 0; - for (const ledIndex of indexes){ - setPixel(ledIndex, pix[i][0], pix[i][1], pix[i][2]) + for (const ledIndex of indexes) { + setPixel(ledIndex, pix[i][0], pix[i][1], pix[i][2]); i += 1; } } - Sk.sense_hat_emit = function(event, data) { - - if (event && event === 'setpixel') { + Sk.sense_hat_emit = function (event, data) { + if (event && event === "setpixel") { // change the led const ledIndex = data; - const ledData = Sk.sense_hat.pixels[ledIndex]; + const ledData = Sk.sense_hat.pixels[ledIndex]; // Convert LED-RGB to RGB565 // and then to RGB555 Sk.sense_hat.pixels[ledIndex] = [ ledData[0] & ~7, ledData[1] & ~3, - ledData[2] & ~7 + ledData[2] & ~7, ]; - setPixel(ledIndex, parseInt(ledData[0]*255), parseInt(ledData[1]*255), parseInt(ledData[2]*255)); - } - else if (event && event === 'setpixels') { + setPixel( + ledIndex, + parseInt(ledData[0] * 255), + parseInt(ledData[1] * 255), + parseInt(ledData[2] * 255), + ); + } else if (event && event === "setpixels") { setPixels(data, Sk.sense_hat.pixels); } - invalidate() - } + invalidate(); + }; - return ( - <primitive object={scene} scale={4} /> - ) -} + return <primitive object={scene} scale={4} />; +}; -export default FlightCase +export default FlightCase; diff --git a/src/components/AstroPiModel/FlightCase.test.js b/src/components/AstroPiModel/FlightCase.test.js index a7f87d15b..b0fbe1ef6 100644 --- a/src/components/AstroPiModel/FlightCase.test.js +++ b/src/components/AstroPiModel/FlightCase.test.js @@ -1,10 +1,8 @@ import React from "react"; -import ReactThreeTestRenderer from '@react-three/test-renderer'; +import ReactThreeTestRenderer from "@react-three/test-renderer"; import FlightCase from "./FlightCase"; test("3D model loads and renders", async () => { - await ReactThreeTestRenderer.create(<FlightCase/>) -}) - - + await ReactThreeTestRenderer.create(<FlightCase />); +}); diff --git a/src/components/AstroPiModel/Lighting.js b/src/components/AstroPiModel/Lighting.js index d67f72909..5876b5a3d 100644 --- a/src/components/AstroPiModel/Lighting.js +++ b/src/components/AstroPiModel/Lighting.js @@ -1,19 +1,44 @@ -import React from 'react'; +import React from "react"; const Lighting = () => { - - return( - <> + return ( + <> <ambientLight intensity={0.25} /> - <pointLight intensity={0.35} angle={28} penumbra={2} position={[0.418, 16.199, 0.300]} castShadow /> - <pointLight position={[-16.116, 14.37, 8.208]} intensity={0.5} castShadow/> - <pointLight position={[-16.109, 18.021, -8.207]} intensity={0.5} castShadow/> - <pointLight position={[14.904, 12.198, -1.832]} intensity={0.17} castShadow/> - <pointLight position={[- 0.462, 8.89, 14.520]} intensity={0.43} castShadow/> - <pointLight position={[3.235, 11.486, - 12.541]} intensity={0.2} castShadow/> - <pointLight position={[0.0, -20.0, 0.0]} intensity={0.5} castShadow/> + <pointLight + intensity={0.35} + angle={28} + penumbra={2} + position={[0.418, 16.199, 0.3]} + castShadow + /> + <pointLight + position={[-16.116, 14.37, 8.208]} + intensity={0.5} + castShadow + /> + <pointLight + position={[-16.109, 18.021, -8.207]} + intensity={0.5} + castShadow + /> + <pointLight + position={[14.904, 12.198, -1.832]} + intensity={0.17} + castShadow + /> + <pointLight + position={[-0.462, 8.89, 14.52]} + intensity={0.43} + castShadow + /> + <pointLight + position={[3.235, 11.486, -12.541]} + intensity={0.2} + castShadow + /> + <pointLight position={[0.0, -20.0, 0.0]} intensity={0.5} castShadow /> </> - ) -} + ); +}; -export default Lighting +export default Lighting; diff --git a/src/components/AstroPiModel/Lighting.test.js b/src/components/AstroPiModel/Lighting.test.js index 375ee5589..5de81ab5b 100644 --- a/src/components/AstroPiModel/Lighting.test.js +++ b/src/components/AstroPiModel/Lighting.test.js @@ -1,7 +1,7 @@ import React from "react"; -import ReactThreeTestRenderer from '@react-three/test-renderer'; +import ReactThreeTestRenderer from "@react-three/test-renderer"; import Lighting from "./Lighting"; test("Lighting component renders", async () => { - await ReactThreeTestRenderer.create(<Lighting/>) -}) + await ReactThreeTestRenderer.create(<Lighting />); +}); diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationPanel.js b/src/components/AstroPiModel/OrientationPanel/OrientationPanel.js index 2114b9fda..43bf4da74 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationPanel.js +++ b/src/components/AstroPiModel/OrientationPanel/OrientationPanel.js @@ -1,27 +1,35 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import '../AstroPiModel.scss'; -import OrientationReading from './OrientationReading'; -import OrientationResetButton from './OrientationResetButton'; +import React from "react"; +import { useTranslation } from "react-i18next"; +import "../AstroPiModel.scss"; +import OrientationReading from "./OrientationReading"; +import OrientationResetButton from "./OrientationResetButton"; const OrientationPanel = (props) => { - - const { t } = useTranslation() - const {orientation, resetOrientation} = props + const { t } = useTranslation(); + const { orientation, resetOrientation } = props; return ( <div className="sense-hat-model-orientation"> <div className="sense-hat-model-orientation__spacing"></div> <div className="sense-hat-model-orientation__values"> - <OrientationReading name={t('output.senseHat.model.roll')} value={orientation[0]} /> - <OrientationReading name={t('output.senseHat.model.pitch')} value={orientation[1]} /> - <OrientationReading name={t('output.senseHat.model.yaw')} value={orientation[2]} /> + <OrientationReading + name={t("output.senseHat.model.roll")} + value={orientation[0]} + /> + <OrientationReading + name={t("output.senseHat.model.pitch")} + value={orientation[1]} + /> + <OrientationReading + name={t("output.senseHat.model.yaw")} + value={orientation[2]} + /> </div> <div className="sense-hat-model-orientation__reset-btn"> - <OrientationResetButton resetOrientation={resetOrientation}/> + <OrientationResetButton resetOrientation={resetOrientation} /> </div> </div> - ) + ); }; -export default OrientationPanel +export default OrientationPanel; diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.js b/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.js index 0492762f1..04f5e5d24 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.js +++ b/src/components/AstroPiModel/OrientationPanel/OrientationPanel.test.js @@ -1,16 +1,21 @@ import React from "react"; -import { render } from "@testing-library/react" +import { render } from "@testing-library/react"; import OrientationPanel from "./OrientationPanel"; let panel; -const resetFunction = jest.fn() +const resetFunction = jest.fn(); beforeAll(() => { - panel = render(<OrientationPanel orientation={[278, 84, 327]} resetOrientation={resetFunction}/>) -}) + panel = render( + <OrientationPanel + orientation={[278, 84, 327]} + resetOrientation={resetFunction} + />, + ); +}); test("Renders roll, pitch and yaw values", () => { - expect(panel.queryByText("output.senseHat.model.roll: 278")).not.toBeNull() - expect(panel.queryByText("output.senseHat.model.pitch: 84")).not.toBeNull() - expect(panel.queryByText("output.senseHat.model.yaw: 327")).not.toBeNull() -}) + expect(panel.queryByText("output.senseHat.model.roll: 278")).not.toBeNull(); + expect(panel.queryByText("output.senseHat.model.pitch: 84")).not.toBeNull(); + expect(panel.queryByText("output.senseHat.model.yaw: 327")).not.toBeNull(); +}); diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationReading.js b/src/components/AstroPiModel/OrientationPanel/OrientationReading.js index 8a70116a6..939402b0d 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationReading.js +++ b/src/components/AstroPiModel/OrientationPanel/OrientationReading.js @@ -1,14 +1,14 @@ -import React from 'react'; -import '../AstroPiModel.scss'; +import React from "react"; +import "../AstroPiModel.scss"; const OrientationReading = (props) => { - const {name, value} = props + const { name, value } = props; return ( - <span className="sense-hat-model-orientation__reading"> - {name}: {Math.round(value)} - </span> - ) -} + <span className="sense-hat-model-orientation__reading"> + {name}: {Math.round(value)} + </span> + ); +}; -export default OrientationReading +export default OrientationReading; diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationReading.test.js b/src/components/AstroPiModel/OrientationPanel/OrientationReading.test.js index 0e4d0ecd1..36acd69ae 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationReading.test.js +++ b/src/components/AstroPiModel/OrientationPanel/OrientationReading.test.js @@ -1,17 +1,17 @@ import React from "react"; -import { render } from "@testing-library/react" +import { render } from "@testing-library/react"; import OrientationReading from "./OrientationReading"; let reading; beforeEach(() => { - reading = render(<OrientationReading name={"foo"} value={123.8}/>) -}) + reading = render(<OrientationReading name={"foo"} value={123.8} />); +}); test("Renders name", () => { - expect(reading.queryByText(/foo/)).not.toBeNull() -}) + expect(reading.queryByText(/foo/)).not.toBeNull(); +}); test("Renders rounded value", () => { - expect(reading.queryByText(/124/)).not.toBeNull() -}) + expect(reading.queryByText(/124/)).not.toBeNull(); +}); diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.js b/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.js index 0da038da0..889eaf6fb 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.js +++ b/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.js @@ -1,16 +1,15 @@ -import React from 'react'; -import '../AstroPiModel.scss'; -import { ResetIcon } from '../../../Icons'; +import React from "react"; +import "../AstroPiModel.scss"; +import { ResetIcon } from "../../../Icons"; const OrientationResetButton = (props) => { - - const {resetOrientation} = props; + const { resetOrientation } = props; return ( - <button onClick={ e => resetOrientation(e)}> - <ResetIcon/> + <button onClick={(e) => resetOrientation(e)}> + <ResetIcon /> </button> - ) -} + ); +}; -export default OrientationResetButton +export default OrientationResetButton; diff --git a/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.js b/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.js index 260ae7562..0f9c37c86 100644 --- a/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.js +++ b/src/components/AstroPiModel/OrientationPanel/OrientationResetButton.test.js @@ -1,15 +1,17 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react" +import { render, fireEvent } from "@testing-library/react"; import OrientationResetButton from "./OrientationResetButton"; let resetButton; -const resetFunction = jest.fn() +const resetFunction = jest.fn(); beforeEach(() => { - resetButton = render(<OrientationResetButton resetOrientation={resetFunction}/>) -}) + resetButton = render( + <OrientationResetButton resetOrientation={resetFunction} />, + ); +}); test("Clicking button calls reset function", () => { - fireEvent.click(resetButton.getByRole("button")) - expect(resetFunction).toHaveBeenCalledTimes(1) -}) + fireEvent.click(resetButton.getByRole("button")); + expect(resetFunction).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/AstroPiModel/Simulator.js b/src/components/AstroPiModel/Simulator.js index 7d008b6de..bd9d1d8fe 100644 --- a/src/components/AstroPiModel/Simulator.js +++ b/src/components/AstroPiModel/Simulator.js @@ -1,16 +1,16 @@ -import React from 'react'; -import * as THREE from 'three'; +import React from "react"; +import * as THREE from "three"; import { ResizeObserver } from "@juggle/resize-observer"; import { Canvas } from "@react-three/fiber"; import { OrbitControls, PerspectiveCamera } from "@react-three/drei"; -import Lighting from './Lighting'; -import { Suspense } from 'react'; +import Lighting from "./Lighting"; +import { Suspense } from "react"; -import { extractRollPitchYaw } from '../../utils/Orientation'; -import FlightCase from './FlightCase' -import './AstroPiModel.scss'; +import { extractRollPitchYaw } from "../../utils/Orientation"; +import FlightCase from "./FlightCase"; +import "./AstroPiModel.scss"; -var isDragging=false +var isDragging = false; var targetRotationX = 0.5; var targetRotationY = 0.2; var mouseX = 0; @@ -20,36 +20,39 @@ var mouseYOnMouseDown = 0; var windowHalfX = window.innerWidth / 2; var windowHalfY = window.innerHeight / 2; var slowingFactor = 0.25; -const rotationScaleFactor = 0.00025 +const rotationScaleFactor = 0.00025; const Simulator = (props) => { - const {updateOrientation} = props + const { updateOrientation } = props; const handleDragStart = (e) => { - isDragging=true + isDragging = true; mouseXOnMouseDown = e.clientX - windowHalfX; mouseYOnMouseDown = e.clientY - windowHalfY; - } + }; const handleDragStop = () => { - isDragging=false - } + isDragging = false; + }; const dragModel = (e) => { mouseX = e.clientX - windowHalfX; - targetRotationX = ( mouseX - mouseXOnMouseDown ) * rotationScaleFactor; + targetRotationX = (mouseX - mouseXOnMouseDown) * rotationScaleFactor; mouseY = e.clientY - windowHalfY; - targetRotationY = ( mouseY - mouseYOnMouseDown ) * rotationScaleFactor; - - if(isDragging) { - window.mod.rotateOnWorldAxis(new THREE.Vector3(0, 0, -1), targetRotationX); + targetRotationY = (mouseY - mouseYOnMouseDown) * rotationScaleFactor; + + if (isDragging) { + window.mod.rotateOnWorldAxis( + new THREE.Vector3(0, 0, -1), + targetRotationX, + ); window.mod.rotateOnWorldAxis(new THREE.Vector3(1, 0, 0), targetRotationY); updateOrientation( extractRollPitchYaw( - window.mod.rotation.x, - window.mod.rotation.y, - window.mod.rotation.z - ) - ) + window.mod.rotation.x, + window.mod.rotation.y, + window.mod.rotation.z, + ), + ); targetRotationY = targetRotationY * (1 - slowingFactor); targetRotationX = targetRotationX * (1 - slowingFactor); } @@ -57,21 +60,32 @@ const Simulator = (props) => { return ( <Canvas - frameloop='demand' + frameloop="demand" onPointerDown={handleDragStart} onPointerUp={handleDragStop} onPointerOut={handleDragStop} onPointerMove={dragModel} - resize={{polyfill: ResizeObserver}} + resize={{ polyfill: ResizeObserver }} > <Lighting /> <Suspense fallback={null}> - <PerspectiveCamera makeDefault fov={25} near={1} far={20000} position={[0, 1.5, 0]} /> + <PerspectiveCamera + makeDefault + fov={25} + near={1} + far={20000} + position={[0, 1.5, 0]} + /> <FlightCase /> - <OrbitControls enableRotate = {false} enablePan = {false} enableZoom = {false} enabled = {false} /> + <OrbitControls + enableRotate={false} + enablePan={false} + enableZoom={false} + enabled={false} + /> </Suspense> </Canvas> - ) + ); }; -export default Simulator +export default Simulator; diff --git a/src/components/AstroPiModel/Simulator.test.js b/src/components/AstroPiModel/Simulator.test.js index 5480f4c12..d52708bd0 100644 --- a/src/components/AstroPiModel/Simulator.test.js +++ b/src/components/AstroPiModel/Simulator.test.js @@ -7,27 +7,27 @@ window.mod = { rotation: { x: 0, y: 0, - z: 0 - } -} + z: 0, + }, +}; test("Three canvas renders", () => { - render(<Simulator/>) -}) + render(<Simulator />); +}); test("Moving pointer over model does not change orientation", () => { - const updateOrientation = jest.fn() - const simulator = render(<Simulator updateOrientation = {updateOrientation}/>) - const canvas = simulator.container.querySelector("canvas") - fireEvent.pointerMove(canvas) - expect(updateOrientation).not.toHaveBeenCalled() -}) + const updateOrientation = jest.fn(); + const simulator = render(<Simulator updateOrientation={updateOrientation} />); + const canvas = simulator.container.querySelector("canvas"); + fireEvent.pointerMove(canvas); + expect(updateOrientation).not.toHaveBeenCalled(); +}); test("Dragging model changes orientation", async () => { - const updateOrientation = jest.fn() - const simulator = render(<Simulator updateOrientation = {updateOrientation}/>) - const canvas = simulator.container.querySelector("canvas") - fireEvent.pointerDown(canvas) - fireEvent.pointerMove(canvas) - expect(updateOrientation).toHaveBeenCalled() -}) + const updateOrientation = jest.fn(); + const simulator = render(<Simulator updateOrientation={updateOrientation} />); + const canvas = simulator.container.querySelector("canvas"); + fireEvent.pointerDown(canvas); + fireEvent.pointerMove(canvas); + expect(updateOrientation).toHaveBeenCalled(); +}); diff --git a/src/components/BetaBanner/BetaBanner.js b/src/components/BetaBanner/BetaBanner.js index 28ca17500..c4383b29f 100644 --- a/src/components/BetaBanner/BetaBanner.js +++ b/src/components/BetaBanner/BetaBanner.js @@ -6,41 +6,56 @@ import { CloseIcon } from "../../Icons"; import Button from "../Button/Button"; import { showBetaModal } from "../Editor/EditorSlice"; -import './BetaBanner.scss' +import "./BetaBanner.scss"; const BetaBanner = () => { - - const dispatch = useDispatch() - const { t } = useTranslation() - const [cookies, setCookie] = useCookies(['betaBannerDismissed']) + const dispatch = useDispatch(); + const { t } = useTranslation(); + const [cookies, setCookie] = useCookies(["betaBannerDismissed"]); const closeBanner = () => { - setCookie('betaBannerDismissed', 'true', { path: '/' }) - } - const showModal = () => { dispatch(showBetaModal()) } - const isShowing = !cookies.betaBannerDismissed + setCookie("betaBannerDismissed", "true", { path: "/" }); + }; + const showModal = () => { + dispatch(showBetaModal()); + }; + const isShowing = !cookies.betaBannerDismissed; const handleKeyDown = (e) => { - const enterKey = 13 - const spaceKey = 32 + const enterKey = 13; + const spaceKey = 32; if (e.keyCode === enterKey || e.keyCode === spaceKey) { e.preventDefault(); - showModal() + showModal(); } - } + }; - return ( - isShowing ? - (<div className='editor-banner editor-banner--beta'> - <span className = 'editor-banner--beta__icon'>Beta</span> - <span className='editor-banner__message'> - {t('betaBanner.message')} - <span className='btn btn--tertiary editor-banner__link' onClick={showModal} tabIndex={0} role='button' onKeyDown={handleKeyDown}>{t('betaBanner.modalLink')}</span> + return isShowing ? ( + <div className="editor-banner editor-banner--beta"> + <span className="editor-banner--beta__icon">Beta</span> + <span className="editor-banner__message"> + {t("betaBanner.message")} + <span + className="btn btn--tertiary editor-banner__link" + onClick={showModal} + tabIndex={0} + role="button" + onKeyDown={handleKeyDown} + > + {t("betaBanner.modalLink")} </span> - <Button className='btn--tertiary editor-banner__close-button' label={t('betaBanner.buttonLabel')} title={t('betaBanner.buttonLabel')} ButtonIcon={CloseIcon} onClickHandler={closeBanner} /> - </div>) - : <></> - ) -} + </span> + <Button + className="btn--tertiary editor-banner__close-button" + label={t("betaBanner.buttonLabel")} + title={t("betaBanner.buttonLabel")} + ButtonIcon={CloseIcon} + onClickHandler={closeBanner} + /> + </div> + ) : ( + <></> + ); +}; -export default BetaBanner +export default BetaBanner; diff --git a/src/components/BetaBanner/BetaBanner.test.js b/src/components/BetaBanner/BetaBanner.test.js index ee10b7482..d5508bab8 100644 --- a/src/components/BetaBanner/BetaBanner.test.js +++ b/src/components/BetaBanner/BetaBanner.test.js @@ -1,45 +1,44 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import { Cookies, CookiesProvider } from 'react-cookie'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import { Cookies, CookiesProvider } from "react-cookie"; import BetaBanner from "./BetaBanner"; -let cookies -let store +let cookies; +let store; beforeEach(() => { cookies = new Cookies(); - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = {} - store = mockStore(initialState); + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = {}; + store = mockStore(initialState); render( <CookiesProvider cookies={cookies}> <Provider store={store}> <BetaBanner /> </Provider> - </CookiesProvider> - ) -}) + </CookiesProvider>, + ); +}); -test('Banner shows', () => { - expect(screen.queryByText('betaBanner.message')).toBeInTheDocument() -}) +test("Banner shows", () => { + expect(screen.queryByText("betaBanner.message")).toBeInTheDocument(); +}); -test('Clicking close button sets cookie', () => { - const closeButton = screen.getAllByRole('button').pop() - fireEvent.click(closeButton) - expect(cookies.cookies.betaBannerDismissed).toBe('true') +test("Clicking close button sets cookie", () => { + const closeButton = screen.getAllByRole("button").pop(); + fireEvent.click(closeButton); + expect(cookies.cookies.betaBannerDismissed).toBe("true"); +}); -}) - -test('Clicking link dispatches modal action', () => { - const modalLink = screen.queryByText('betaBanner.modalLink') - fireEvent.click(modalLink) - expect(store.getActions()).toEqual([{type: 'editor/showBetaModal'}]) -}) +test("Clicking link dispatches modal action", () => { + const modalLink = screen.queryByText("betaBanner.modalLink"); + fireEvent.click(modalLink); + expect(store.getActions()).toEqual([{ type: "editor/showBetaModal" }]); +}); afterEach(() => { - cookies.remove('betaBannerDismissed') -}) + cookies.remove("betaBannerDismissed"); +}); diff --git a/src/components/Callback.js b/src/components/Callback.js index d50ab1f8d..590b72b08 100644 --- a/src/components/Callback.js +++ b/src/components/Callback.js @@ -1,30 +1,34 @@ import React from "react"; import { connect } from "react-redux"; import { CallbackComponent } from "redux-oidc"; -import { useNavigate } from 'react-router-dom' +import { useNavigate } from "react-router-dom"; import userManager from "../utils/userManager"; const Callback = () => { - let navigate = useNavigate() + let navigate = useNavigate(); - const previousRoute = localStorage.getItem('location') + const previousRoute = localStorage.getItem("location"); const onSuccess = () => { - localStorage.removeItem('location') - window.plausible('Login successful') - navigate(previousRoute) - } + localStorage.removeItem("location"); + window.plausible("Login successful"); + navigate(previousRoute); + }; const onError = (error) => { navigate(previousRoute); console.error(error); - } + }; return ( - <CallbackComponent userManager={userManager} successCallback={onSuccess} errorCallback={(error) => onError(error)}> + <CallbackComponent + userManager={userManager} + successCallback={onSuccess} + errorCallback={(error) => onError(error)} + > <div>Redirecting...</div> </CallbackComponent> ); -} +}; export default connect()(Callback); diff --git a/src/components/Editor/DraggableTabs/DraggableTab.js b/src/components/Editor/DraggableTabs/DraggableTab.js index 785f2d1c9..4154400aa 100644 --- a/src/components/Editor/DraggableTabs/DraggableTab.js +++ b/src/components/Editor/DraggableTabs/DraggableTab.js @@ -4,41 +4,57 @@ import { useDispatch, useSelector } from "react-redux"; import { Tab } from "react-tabs"; import { setFocussedFileIndex } from "../EditorSlice"; -import './DraggableTabs.scss' +import "./DraggableTabs.scss"; -const DraggableTab = ({children, panelIndex, fileIndex, ...otherProps}) => { - const openFiles = useSelector((state) => state.editor.openFiles) - const openFilesCount = openFiles[panelIndex].length - const dispatch = useDispatch() +const DraggableTab = ({ children, panelIndex, fileIndex, ...otherProps }) => { + const openFiles = useSelector((state) => state.editor.openFiles); + const openFilesCount = openFiles[panelIndex].length; + const dispatch = useDispatch(); const switchToFileTab = (panelIndex, fileIndex) => { - dispatch(setFocussedFileIndex({panelIndex, fileIndex})) - } + dispatch(setFocussedFileIndex({ panelIndex, fileIndex })); + }; const onKeyPress = (e, panelIndex, fileIndex) => { - if (e.code === 'ArrowRight') { - switchToFileTab(panelIndex, (fileIndex + 1) % openFilesCount) - } else if (e.code === 'ArrowLeft') { - switchToFileTab(panelIndex, (fileIndex + openFilesCount - 1) % openFilesCount ) + if (e.code === "ArrowRight") { + switchToFileTab(panelIndex, (fileIndex + 1) % openFilesCount); + } else if (e.code === "ArrowLeft") { + switchToFileTab( + panelIndex, + (fileIndex + openFilesCount - 1) % openFilesCount, + ); } - } - + }; + return ( - <Draggable draggableId={`draggable${panelIndex}_${fileIndex}`} index={fileIndex}> - {({innerRef, draggableProps, dragHandleProps}) => ( - <div className = 'draggable-tab' ref={innerRef} {...draggableProps} {...dragHandleProps} style={draggableProps.style}> - <Tab onClick = {(e) => { - e.stopPropagation() - switchToFileTab(panelIndex, fileIndex) - }} onKeyDown={e => onKeyPress(e, panelIndex, fileIndex)} {...otherProps} > + <Draggable + draggableId={`draggable${panelIndex}_${fileIndex}`} + index={fileIndex} + > + {({ innerRef, draggableProps, dragHandleProps }) => ( + <div + className="draggable-tab" + ref={innerRef} + {...draggableProps} + {...dragHandleProps} + style={draggableProps.style} + > + <Tab + onClick={(e) => { + e.stopPropagation(); + switchToFileTab(panelIndex, fileIndex); + }} + onKeyDown={(e) => onKeyPress(e, panelIndex, fileIndex)} + {...otherProps} + > {children} </Tab> </div> )} </Draggable> - ) + ); }; -DraggableTab.tabsRole = 'Tab' +DraggableTab.tabsRole = "Tab"; -export default DraggableTab +export default DraggableTab; diff --git a/src/components/Editor/DraggableTabs/DraggableTab.test.js b/src/components/Editor/DraggableTabs/DraggableTab.test.js index ee9237624..487a81de8 100644 --- a/src/components/Editor/DraggableTabs/DraggableTab.test.js +++ b/src/components/Editor/DraggableTabs/DraggableTab.test.js @@ -1,5 +1,5 @@ import React from "react"; -import configureStore from 'redux-mock-store' +import configureStore from "redux-mock-store"; import { fireEvent, render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import DraggableTab from "./DraggableTab"; @@ -7,53 +7,69 @@ import DroppableTabList from "./DroppableTabList"; import { DragDropContext } from "@hello-pangea/dnd"; import { setFocussedFileIndex } from "../EditorSlice"; -describe('when tab is in focus', () => { - let store +describe("when tab is in focus", () => { + let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - name: 'main', - extension: 'py', - content: 'print("hello")' + name: "main", + extension: "py", + content: 'print("hello")', }, { - name: 'a', - extension: 'py', - content: '# Your code here' - } - ] + name: "a", + extension: "py", + content: "# Your code here", + }, + ], }, - openFiles: [['a.py', 'main.py', 'b.py']], - focussedFileIndices: [1] + openFiles: [["a.py", "main.py", "b.py"]], + focussedFileIndices: [1], }, auth: { - user: null - } - } + user: null, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><DragDropContext><DroppableTabList index={0}><DraggableTab panelIndex={0} fileIndex={1}>main.py</DraggableTab></DroppableTabList></DragDropContext></Provider>) - }) - test('pressing right arrow key moves focus to right', () => { - const tab = screen.queryByText('main.py') - fireEvent.keyDown(tab, {code: 'ArrowRight'}) - expect(store.getActions()).toEqual([setFocussedFileIndex({panelIndex: 0, fileIndex: 2})]) - }) + render( + <Provider store={store}> + <DragDropContext> + <DroppableTabList index={0}> + <DraggableTab panelIndex={0} fileIndex={1}> + main.py + </DraggableTab> + </DroppableTabList> + </DragDropContext> + </Provider>, + ); + }); + test("pressing right arrow key moves focus to right", () => { + const tab = screen.queryByText("main.py"); + fireEvent.keyDown(tab, { code: "ArrowRight" }); + expect(store.getActions()).toEqual([ + setFocussedFileIndex({ panelIndex: 0, fileIndex: 2 }), + ]); + }); - test('pressing left arrow key moves focus to left', () => { - const tab = screen.queryByText('main.py') - fireEvent.keyDown(tab, {code: 'ArrowLeft'}) - expect(store.getActions()).toEqual([setFocussedFileIndex({panelIndex: 0, fileIndex: 0})]) - }) + test("pressing left arrow key moves focus to left", () => { + const tab = screen.queryByText("main.py"); + fireEvent.keyDown(tab, { code: "ArrowLeft" }); + expect(store.getActions()).toEqual([ + setFocussedFileIndex({ panelIndex: 0, fileIndex: 0 }), + ]); + }); - test('clicking tab switches focus to it', () => { - const tab = screen.queryByText('main.py') - fireEvent.click(tab) - expect(store.getActions()).toEqual([setFocussedFileIndex({panelIndex: 0, fileIndex: 1})]) - }) -}) + test("clicking tab switches focus to it", () => { + const tab = screen.queryByText("main.py"); + fireEvent.click(tab); + expect(store.getActions()).toEqual([ + setFocussedFileIndex({ panelIndex: 0, fileIndex: 1 }), + ]); + }); +}); diff --git a/src/components/Editor/DraggableTabs/DroppableTabList.js b/src/components/Editor/DraggableTabs/DroppableTabList.js index 837bb59a2..0839e73f7 100644 --- a/src/components/Editor/DraggableTabs/DroppableTabList.js +++ b/src/components/Editor/DraggableTabs/DroppableTabList.js @@ -1,24 +1,28 @@ -import React from "react" -import { Droppable } from "@hello-pangea/dnd" -import { TabList } from "react-tabs" +import React from "react"; +import { Droppable } from "@hello-pangea/dnd"; +import { TabList } from "react-tabs"; -import './DraggableTabs.scss' +import "./DraggableTabs.scss"; -const DroppableTabList = ({children, index, ...otherProps}) => { +const DroppableTabList = ({ children, index, ...otherProps }) => { return ( - <TabList {...otherProps }> - <Droppable direction='horizontal' droppableId={index.toString()}> - {({innerRef, droppableProps, placeholder}) => ( - <div className = 'droppable-tab-list' {...droppableProps} ref={innerRef}> + <TabList {...otherProps}> + <Droppable direction="horizontal" droppableId={index.toString()}> + {({ innerRef, droppableProps, placeholder }) => ( + <div + className="droppable-tab-list" + {...droppableProps} + ref={innerRef} + > {children} {placeholder} </div> )} </Droppable> </TabList> - ) -} + ); +}; -DroppableTabList.tabsRole = 'TabList' +DroppableTabList.tabsRole = "TabList"; -export default DroppableTabList +export default DroppableTabList; diff --git a/src/components/Editor/DraggableTabs/DroppableTabList.test.js b/src/components/Editor/DraggableTabs/DroppableTabList.test.js index c0e3aa39f..9e5b3024b 100644 --- a/src/components/Editor/DraggableTabs/DroppableTabList.test.js +++ b/src/components/Editor/DraggableTabs/DroppableTabList.test.js @@ -4,9 +4,13 @@ import { render, screen } from "@testing-library/react"; import { DragDropContext } from "@hello-pangea/dnd"; beforeEach(() => { - render(<DragDropContext><DroppableTabList index={0}>hello</DroppableTabList></DragDropContext>) -}) + render( + <DragDropContext> + <DroppableTabList index={0}>hello</DroppableTabList> + </DragDropContext>, + ); +}); -test('Renders', () => { - expect(screen.queryByText('hello')).toBeInTheDocument() -}) +test("Renders", () => { + expect(screen.queryByText("hello")).toBeInTheDocument(); +}); diff --git a/src/components/Editor/Editor.js b/src/components/Editor/Editor.js index da0b1b731..ecaeea871 100644 --- a/src/components/Editor/Editor.js +++ b/src/components/Editor/Editor.js @@ -1,17 +1,17 @@ // import './editor.css' -import React, { useRef, useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux' -import { update } from './EditorSlice' +import React, { useRef, useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { update } from "./EditorSlice"; -import { EditorState, basicSetup } from '@codemirror/basic-setup'; -import { EditorView, keymap } from '@codemirror/view'; -import { defaultKeymap } from '@codemirror/commands'; -import { python } from '@codemirror/lang-python'; +import { EditorState, basicSetup } from "@codemirror/basic-setup"; +import { EditorView, keymap } from "@codemirror/view"; +import { defaultKeymap } from "@codemirror/commands"; +import { python } from "@codemirror/lang-python"; function Editor() { const editor = useRef(); - const code = useSelector((state) => state.editor.code) - const dispatch = useDispatch() + const code = useSelector((state) => state.editor.code); + const dispatch = useDispatch(); const onUpdate = EditorView.updateListener.of((v) => { dispatch(update(v.state.doc.toString())); @@ -20,12 +20,7 @@ function Editor() { useEffect(() => { const startState = EditorState.create({ doc: code, - extensions: [ - basicSetup, - keymap.of(defaultKeymap), - python(), - onUpdate, - ], + extensions: [basicSetup, keymap.of(defaultKeymap), python(), onUpdate], }); const view = new EditorView({ state: startState, parent: editor.current }); diff --git a/src/components/Editor/EditorInput/EditorInput.js b/src/components/Editor/EditorInput/EditorInput.js index e80aea9da..6e5eb3d1e 100644 --- a/src/components/Editor/EditorInput/EditorInput.js +++ b/src/components/Editor/EditorInput/EditorInput.js @@ -1,81 +1,111 @@ -import React, { createRef, useEffect, useRef, useState } from 'react' -import { DragDropContext } from '@hello-pangea/dnd' -import { useDispatch, useSelector } from 'react-redux' -import { TabPanel, Tabs } from 'react-tabs' -import classNames from 'classnames'; +import React, { createRef, useEffect, useRef, useState } from "react"; +import { DragDropContext } from "@hello-pangea/dnd"; +import { useDispatch, useSelector } from "react-redux"; +import { TabPanel, Tabs } from "react-tabs"; +import classNames from "classnames"; -import { closeFile, setFocussedFileIndex, setOpenFiles } from '../EditorSlice' -import Button from '../../Button/Button' -import { CloseIcon } from '../../../Icons' -import EditorPanel from '../EditorPanel/EditorPanel' -import DraggableTab from '../DraggableTabs/DraggableTab' -import DroppableTabList from '../DraggableTabs/DroppableTabList' -import RunBar from '../../RunButton/RunBar' +import { closeFile, setFocussedFileIndex, setOpenFiles } from "../EditorSlice"; +import Button from "../../Button/Button"; +import { CloseIcon } from "../../../Icons"; +import EditorPanel from "../EditorPanel/EditorPanel"; +import DraggableTab from "../DraggableTabs/DraggableTab"; +import DroppableTabList from "../DraggableTabs/DroppableTabList"; +import RunBar from "../../RunButton/RunBar"; -import './EditorInput.scss' +import "./EditorInput.scss"; const EditorInput = () => { - const project = useSelector((state) => state.editor.project) - const openFiles = useSelector((state) => state.editor.openFiles) - const focussedFileIndices = useSelector((state) => state.editor.focussedFileIndices) - const dispatch = useDispatch() + const project = useSelector((state) => state.editor.project); + const openFiles = useSelector((state) => state.editor.openFiles); + const focussedFileIndices = useSelector( + (state) => state.editor.focussedFileIndices, + ); + const dispatch = useDispatch(); const onDragStart = (input) => { - const { source } = input - dispatch(setFocussedFileIndex({panelIndex: parseInt(source.droppableId), fileIndex: source.index})) - } + const { source } = input; + dispatch( + setFocussedFileIndex({ + panelIndex: parseInt(source.droppableId), + fileIndex: source.index, + }), + ); + }; const onDragEnd = (result) => { - const { source, destination } = result - if (!destination) return + const { source, destination } = result; + if (!destination) return; - let openFilesData = [...openFiles] - let oldPane = [...openFilesData[source.droppableId]] - const [removed] = oldPane.splice(source.index, 1) - openFilesData[source.droppableId] = [...oldPane] + let openFilesData = [...openFiles]; + let oldPane = [...openFilesData[source.droppableId]]; + const [removed] = oldPane.splice(source.index, 1); + openFilesData[source.droppableId] = [...oldPane]; - let newPane = [...openFilesData[destination.droppableId]] - newPane.splice(destination.index, 0, removed) - openFilesData[destination.droppableId] = [...newPane] - dispatch(setOpenFiles(openFilesData)) - dispatch(setFocussedFileIndex({panelIndex: parseInt(destination.droppableId), fileIndex: destination.index})) + let newPane = [...openFilesData[destination.droppableId]]; + newPane.splice(destination.index, 0, removed); + openFilesData[destination.droppableId] = [...newPane]; + dispatch(setOpenFiles(openFilesData)); + dispatch( + setFocussedFileIndex({ + panelIndex: parseInt(destination.droppableId), + fileIndex: destination.index, + }), + ); if (destination.droppableId !== source.droppableId) { - dispatch(setFocussedFileIndex({panelIndex: parseInt(source.droppableId), fileIndex: Math.max(source.index - 1, 0)})) + dispatch( + setFocussedFileIndex({ + panelIndex: parseInt(source.droppableId), + fileIndex: Math.max(source.index - 1, 0), + }), + ); } - } + }; const closeFileTab = (e, fileName) => { - e.stopPropagation() - dispatch(closeFile(fileName)) - } + e.stopPropagation(); + dispatch(closeFile(fileName)); + }; - const [numberOfComponents, setNumberOfComponents] = useState(project.components.length) - let tabRefs = useRef(project.components.map(createRef)) + const [numberOfComponents, setNumberOfComponents] = useState( + project.components.length, + ); + let tabRefs = useRef(project.components.map(createRef)); useEffect(() => { - setNumberOfComponents(project.components.length) - Array(project.components.length).fill().forEach((_, i) => { - tabRefs.current[i] = tabRefs.current[i] || React.createRef(); - }) - }, [project]) + setNumberOfComponents(project.components.length); + Array(project.components.length) + .fill() + .forEach((_, i) => { + tabRefs.current[i] = tabRefs.current[i] || React.createRef(); + }); + }, [project]); useEffect(() => { focussedFileIndices.forEach((index, i) => { - const fileName = openFiles[i][index] - const componentIndex = project.components.findIndex(file => `${file.name}.${file.extension}`=== fileName) - const fileRef = tabRefs.current[componentIndex] + const fileName = openFiles[i][index]; + const componentIndex = project.components.findIndex( + (file) => `${file.name}.${file.extension}` === fileName, + ); + const fileRef = tabRefs.current[componentIndex]; if (fileRef && fileRef.current) { - fileRef.current.parentElement.scrollIntoView() + fileRef.current.parentElement.scrollIntoView(); } - }) - }, [focussedFileIndices, openFiles, numberOfComponents, project]) + }); + }, [focussedFileIndices, openFiles, numberOfComponents, project]); return ( - <DragDropContext onDragStart={input => onDragStart(input)} onDragEnd={result => onDragEnd(result)}> - <div className = 'editor-input'> + <DragDropContext + onDragStart={(input) => onDragStart(input)} + onDragEnd={(result) => onDragEnd(result)} + > + <div className="editor-input"> {openFiles.map((panel, panelIndex) => ( - <Tabs key={panelIndex} selectedIndex={focussedFileIndices[panelIndex]} onSelect={() => {}}> - <div className='react-tabs__tab-container'> + <Tabs + key={panelIndex} + selectedIndex={focussedFileIndices[panelIndex]} + onSelect={() => {}} + > + <div className="react-tabs__tab-container"> <DroppableTabList index={panelIndex}> {panel.map((fileName, fileIndex) => ( <DraggableTab @@ -84,17 +114,31 @@ const EditorInput = () => { panelIndex={panelIndex} > <span - className={classNames( - 'react-tabs__tab-inner', - {'react-tabs__tab-inner--split': !["main.py", "index.html"].includes(fileName)} - )} - ref={tabRefs.current[project.components.findIndex(file => `${file.name}.${file.extension}`===fileName)]} + className={classNames("react-tabs__tab-inner", { + "react-tabs__tab-inner--split": ![ + "main.py", + "index.html", + ].includes(fileName), + })} + ref={ + tabRefs.current[ + project.components.findIndex( + (file) => + `${file.name}.${file.extension}` === fileName, + ) + ] + } > {fileName} - {!["main.py", "index.html"].includes(fileName) ? - <Button className='btn--tertiary react-tabs__tab-inner-close-btn' label='close' onKeyDown={(e) => e.stopPropagation()} onClickHandler={(e) => closeFileTab(e, fileName)} ButtonIcon={() => <CloseIcon scaleFactor={0.85}/> }/> - : null - } + {!["main.py", "index.html"].includes(fileName) ? ( + <Button + className="btn--tertiary react-tabs__tab-inner-close-btn" + label="close" + onKeyDown={(e) => e.stopPropagation()} + onClickHandler={(e) => closeFileTab(e, fileName)} + ButtonIcon={() => <CloseIcon scaleFactor={0.85} />} + /> + ) : null} </span> </DraggableTab> ))} @@ -102,7 +146,10 @@ const EditorInput = () => { </div> {panel.map((fileName, i) => ( <TabPanel key={i}> - <EditorPanel fileName={fileName.split('.')[0]} extension={fileName.split('.').slice(1).join('.')} /> + <EditorPanel + fileName={fileName.split(".")[0]} + extension={fileName.split(".").slice(1).join(".")} + /> </TabPanel> ))} <RunBar /> @@ -110,6 +157,6 @@ const EditorInput = () => { ))} </div> </DragDropContext> - ) -} -export default EditorInput + ); +}; +export default EditorInput; diff --git a/src/components/Editor/EditorInput/EditorInput.test.js b/src/components/Editor/EditorInput/EditorInput.test.js index 110881f35..0b7d82c38 100644 --- a/src/components/Editor/EditorInput/EditorInput.test.js +++ b/src/components/Editor/EditorInput/EditorInput.test.js @@ -1,82 +1,111 @@ -import React from "react" -import configureStore from 'redux-mock-store' -import EditorInput from "./EditorInput" -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from "react-redux" -import { closeFile, setFocussedFileIndex, setOpenFiles } from "../EditorSlice" +import React from "react"; +import configureStore from "redux-mock-store"; +import EditorInput from "./EditorInput"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { closeFile, setFocussedFileIndex, setOpenFiles } from "../EditorSlice"; -window.HTMLElement.prototype.scrollIntoView = jest.fn() +window.HTMLElement.prototype.scrollIntoView = jest.fn(); -describe('Tab interactions', () => { - let store +describe("Tab interactions", () => { + let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - name: 'main', - extension: 'py', - content: 'print("hello")' + name: "main", + extension: "py", + content: 'print("hello")', }, { - name: 'a', - extension: 'py', - content: '# Your code here' - } - ] + name: "a", + extension: "py", + content: "# Your code here", + }, + ], }, - openFiles: [['main.py', 'a.py']], - focussedFileIndices: [1] + openFiles: [["main.py", "a.py"]], + focussedFileIndices: [1], }, auth: { - user: null - } - } + user: null, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><div id="app"><EditorInput/></div></Provider>) - }) + render( + <Provider store={store}> + <div id="app"> + <EditorInput /> + </div> + </Provider>, + ); + }); test("Renders content of focussed file", () => { - expect(screen.queryByText('# Your code here')).toBeInTheDocument() - }) + expect(screen.queryByText("# Your code here")).toBeInTheDocument(); + }); test("Scrolls focussed file into view", () => { - expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled() - }) + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); - test('Clicking the file close button dispatches close action', () => { - const closeButton = screen.queryAllByRole('button')[2] - fireEvent.click(closeButton) - expect(store.getActions()).toEqual([closeFile('a.py')]) - }) + test("Clicking the file close button dispatches close action", () => { + const closeButton = screen.queryAllByRole("button")[2]; + fireEvent.click(closeButton); + expect(store.getActions()).toEqual([closeFile("a.py")]); + }); - test('Focusses tab when dragged', async () => { - const tab = screen.queryByText('main.py').parentElement.parentElement - fireEvent.keyDown(tab, {key: ' ', keyCode: 32, code: 'Space'}) - await waitFor(() => expect(store.getActions()).toEqual([setFocussedFileIndex({panelIndex: 0, fileIndex: 0})])) - }) + test("Focusses tab when dragged", async () => { + const tab = screen.queryByText("main.py").parentElement.parentElement; + fireEvent.keyDown(tab, { key: " ", keyCode: 32, code: "Space" }); + await waitFor(() => + expect(store.getActions()).toEqual([ + setFocussedFileIndex({ panelIndex: 0, fileIndex: 0 }), + ]), + ); + }); - test('moves tab correctly when dropped', async () => { - const tab = screen.queryByText('main.py').parentElement.parentElement - fireEvent.keyDown(tab, {key: ' ', keyCode: 32, code: 'Space'}) - fireEvent.keyDown(tab, {key: 'ArrowRight', keyCode: 39, code: 'ArrowRight'}) - fireEvent.keyDown(tab, {key: ' ', keyCode: 32, code: 'Space'}) + test("moves tab correctly when dropped", async () => { + const tab = screen.queryByText("main.py").parentElement.parentElement; + fireEvent.keyDown(tab, { key: " ", keyCode: 32, code: "Space" }); + fireEvent.keyDown(tab, { + key: "ArrowRight", + keyCode: 39, + code: "ArrowRight", + }); + fireEvent.keyDown(tab, { key: " ", keyCode: 32, code: "Space" }); - const moveTabAction = setOpenFiles([['a.py', 'main.py']]) - await waitFor(() => expect(store.getActions()).toEqual(expect.arrayContaining([moveTabAction]))) - }) + const moveTabAction = setOpenFiles([["a.py", "main.py"]]); + await waitFor(() => + expect(store.getActions()).toEqual( + expect.arrayContaining([moveTabAction]), + ), + ); + }); - test('focusses dropped tab', async () => { - const tab = screen.queryByText('main.py').parentElement.parentElement - fireEvent.keyDown(tab, {key: ' ', keyCode: 32, code: 'Space'}) - fireEvent.keyDown(tab, {key: 'ArrowRight', keyCode: 39, code: 'ArrowRight'}) - fireEvent.keyDown(tab, {key: ' ', keyCode: 32, code: 'Space'}) + test("focusses dropped tab", async () => { + const tab = screen.queryByText("main.py").parentElement.parentElement; + fireEvent.keyDown(tab, { key: " ", keyCode: 32, code: "Space" }); + fireEvent.keyDown(tab, { + key: "ArrowRight", + keyCode: 39, + code: "ArrowRight", + }); + fireEvent.keyDown(tab, { key: " ", keyCode: 32, code: "Space" }); - const switchFocusAction = setFocussedFileIndex({panelIndex: 0, fileIndex: 1}) - await waitFor(() => expect(store.getActions()).toEqual(expect.arrayContaining([switchFocusAction]))) - }) -}) + const switchFocusAction = setFocussedFileIndex({ + panelIndex: 0, + fileIndex: 1, + }); + await waitFor(() => + expect(store.getActions()).toEqual( + expect.arrayContaining([switchFocusAction]), + ), + ); + }); +}); diff --git a/src/components/Editor/EditorPanel/EditorPanel.js b/src/components/Editor/EditorPanel/EditorPanel.js index 0dadaa9c5..8f89e2e87 100644 --- a/src/components/Editor/EditorPanel/EditorPanel.js +++ b/src/components/Editor/EditorPanel/EditorPanel.js @@ -1,64 +1,74 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import './EditorPanel.scss' -import React, { useRef, useEffect, useContext } from 'react'; -import { useSelector, useDispatch } from 'react-redux' -import { updateProjectComponent } from '../EditorSlice' -import { useCookies } from 'react-cookie'; +import "./EditorPanel.scss"; +import React, { useRef, useEffect, useContext } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { updateProjectComponent } from "../EditorSlice"; +import { useCookies } from "react-cookie"; import { useTranslation } from "react-i18next"; -import { basicSetup } from 'codemirror' -import { EditorView, keymap } from '@codemirror/view' -import { EditorState } from '@codemirror/state' -import { defaultKeymap, indentWithTab } from '@codemirror/commands' -import { indentationMarkers } from '@replit/codemirror-indentation-markers' -import { indentUnit } from '@codemirror/language'; +import { basicSetup } from "codemirror"; +import { EditorView, keymap } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import { defaultKeymap, indentWithTab } from "@codemirror/commands"; +import { indentationMarkers } from "@replit/codemirror-indentation-markers"; +import { indentUnit } from "@codemirror/language"; -import { html } from '@codemirror/lang-html' -import { css } from '@codemirror/lang-css' -import { python } from '@codemirror/lang-python' +import { html } from "@codemirror/lang-html"; +import { css } from "@codemirror/lang-css"; +import { python } from "@codemirror/lang-python"; -import { editorLightTheme } from '../editorLightTheme' -import { editorDarkTheme } from '../editorDarkTheme' -import { SettingsContext } from '../../../settings'; +import { editorLightTheme } from "../editorLightTheme"; +import { editorDarkTheme } from "../editorDarkTheme"; +import { SettingsContext } from "../../../settings"; -const EditorPanel = ({ - extension = 'html', - fileName = 'index' -}) => { +const EditorPanel = ({ extension = "html", fileName = "index" }) => { const editor = useRef(); const project = useSelector((state) => state.editor.project); - const [cookies] = useCookies(['theme', 'fontSize']) + const [cookies] = useCookies(["theme", "fontSize"]); const dispatch = useDispatch(); const { t } = useTranslation(); - const settings = useContext(SettingsContext) + const settings = useContext(SettingsContext); const updateStoredProject = (content) => { - dispatch(updateProjectComponent({ extension: extension, name: fileName, code: content})); - } + dispatch( + updateProjectComponent({ + extension: extension, + name: fileName, + code: content, + }), + ); + }; - const label = EditorView.contentAttributes.of({ 'aria-label': t('editorPanel.ariaLabel') }); + const label = EditorView.contentAttributes.of({ + "aria-label": t("editorPanel.ariaLabel"), + }); const onUpdate = EditorView.updateListener.of((viewUpdate) => { - if(viewUpdate.docChanged) { + if (viewUpdate.docChanged) { updateStoredProject(viewUpdate.state.doc.toString()); } }); const getMode = () => { switch (extension) { - case 'html': + case "html": return html(); - case 'css': + case "css": return css(); - case 'py': + case "py": return python(); default: return html(); } - } - const isDarkMode = cookies.theme==="dark" || (!cookies.theme && window.matchMedia("(prefers-color-scheme:dark)").matches) - const editorTheme = isDarkMode ? editorDarkTheme : editorLightTheme + }; + const isDarkMode = + cookies.theme === "dark" || + (!cookies.theme && + window.matchMedia("(prefers-color-scheme:dark)").matches); + const editorTheme = isDarkMode ? editorDarkTheme : editorLightTheme; useEffect(() => { - const code = project.components.find(item => item.extension === extension && item.name === fileName).content; + const code = project.components.find( + (item) => item.extension === extension && item.name === fileName, + ).content; const mode = getMode(); const startState = EditorState.create({ doc: code, @@ -70,7 +80,7 @@ const EditorPanel = ({ onUpdate, editorTheme, indentationMarkers(), - indentUnit.of(' '), + indentUnit.of(" "), ], }); @@ -81,12 +91,13 @@ const EditorPanel = ({ }); // 'aria-hidden' to fix keyboard access accessibility error - view.scrollDOM.setAttribute('aria-hidden', 'true') + view.scrollDOM.setAttribute("aria-hidden", "true"); // Add alt text to hidden images to fix accessibility error - const hiddenImages = view.contentDOM.getElementsByClassName('cm-widgetBuffer'); + const hiddenImages = + view.contentDOM.getElementsByClassName("cm-widgetBuffer"); for (let img of hiddenImages) { - img.setAttribute('role', 'presentation') + img.setAttribute("role", "presentation"); } return () => { @@ -97,6 +108,6 @@ const EditorPanel = ({ return ( <div className={`editor editor--${settings.fontSize}`} ref={editor}></div> ); -} +}; export default EditorPanel; diff --git a/src/components/Editor/EditorPanel/EditorPanel.test.js b/src/components/Editor/EditorPanel/EditorPanel.test.js index cc879ba17..c18c5c953 100644 --- a/src/components/Editor/EditorPanel/EditorPanel.test.js +++ b/src/components/Editor/EditorPanel/EditorPanel.test.js @@ -1,45 +1,44 @@ -import configureStore from 'redux-mock-store' -import { Provider } from "react-redux" -import { SettingsContext } from "../../../settings" -import { render } from '@testing-library/react' -import { axe, toHaveNoViolations }from 'jest-axe' -import EditorPanel from './EditorPanel' +import configureStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { SettingsContext } from "../../../settings"; +import { render } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; +import EditorPanel from "./EditorPanel"; -expect.extend(toHaveNoViolations) +expect.extend(toHaveNoViolations); -describe('When font size is set', () => { +describe("When font size is set", () => { + let editor; - let editor - beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - components: [ - {name: 'main', extension: 'py', content: ''} - ] - } - } - } + components: [{ name: "main", extension: "py", content: "" }], + }, + }, + }; const store = mockStore(initialState); const editorContainer = render( <Provider store={store}> - <SettingsContext.Provider value={{ theme: 'dark', fontSize: 'myFontSize' }}> - <EditorPanel fileName='main' extension='py'/> + <SettingsContext.Provider + value={{ theme: "dark", fontSize: "myFontSize" }} + > + <EditorPanel fileName="main" extension="py" /> </SettingsContext.Provider> - </Provider> - ) - editor = editorContainer.container.querySelector('.editor') - }) + </Provider>, + ); + editor = editorContainer.container.querySelector(".editor"); + }); - test('Font size class is set correctly', () => { - expect(editor).toHaveClass("editor--myFontSize") - }) + test("Font size class is set correctly", () => { + expect(editor).toHaveClass("editor--myFontSize"); + }); - test('Editor panel has no AXE violations', async () => { - const axeResults = await axe(editor) - expect(axeResults).toHaveNoViolations() - }) -}) + test("Editor panel has no AXE violations", async () => { + const axeResults = await axe(editor); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/src/components/Editor/EditorSlice.js b/src/components/Editor/EditorSlice.js index f6e19d4df..c9dbc4dac 100644 --- a/src/components/Editor/EditorSlice.js +++ b/src/components/Editor/EditorSlice.js @@ -13,7 +13,7 @@ export const syncProject = (actionName) => `editor/${actionName}Project`, async ( { project, identifier, locale, accessToken, autosave }, - { rejectWithValue } + { rejectWithValue }, ) => { let response; switch (actionName) { @@ -52,7 +52,7 @@ export const syncProject = (actionName) => return false; } }, - } + }, ); export const loadProjectList = createAsyncThunk( @@ -64,7 +64,7 @@ export const loadProjectList = createAsyncThunk( page, links: parseLinkHeader(response.headers.link), }; - } + }, ); export const EditorSlice = createSlice({ @@ -110,32 +110,42 @@ export const EditorSlice = createSlice({ }, reducers: { closeFile: (state, action) => { - const panelIndex = state.openFiles.map((fileNames) => fileNames.includes(action.payload)).indexOf(true) - const closedFileIndex = state.openFiles[panelIndex].indexOf(action.payload) - state.openFiles[panelIndex] = state.openFiles[panelIndex].filter(fileName => fileName !== action.payload) + const panelIndex = state.openFiles + .map((fileNames) => fileNames.includes(action.payload)) + .indexOf(true); + const closedFileIndex = state.openFiles[panelIndex].indexOf( + action.payload, + ); + state.openFiles[panelIndex] = state.openFiles[panelIndex].filter( + (fileName) => fileName !== action.payload, + ); if ( - state.focussedFileIndices[panelIndex] >= state.openFiles[panelIndex].length || + state.focussedFileIndices[panelIndex] >= + state.openFiles[panelIndex].length || closedFileIndex < state.focussedFileIndices[panelIndex] ) { - state.focussedFileIndices[panelIndex]-- + state.focussedFileIndices[panelIndex]--; } }, openFile: (state, action) => { - const firstPanelIndex = 0 + const firstPanelIndex = 0; if (!state.openFiles.flat().includes(action.payload)) { - state.openFiles[firstPanelIndex].push(action.payload) + state.openFiles[firstPanelIndex].push(action.payload); } - state.focussedFileIndices[firstPanelIndex] = state.openFiles[firstPanelIndex].indexOf(action.payload) + state.focussedFileIndices[firstPanelIndex] = state.openFiles[ + firstPanelIndex + ].indexOf(action.payload); }, setOpenFiles: (state, action) => { - state.openFiles = action.payload + state.openFiles = action.payload; }, addFilePanel: (state) => { - state.openFiles.push([]) - state.focussedFileIndices.push(0) + state.openFiles.push([]); + state.focussedFileIndices.push(0); }, setFocussedFileIndex: (state, action) => { - state.focussedFileIndices[action.payload.panelIndex] = action.payload.fileIndex + state.focussedFileIndices[action.payload.panelIndex] = + action.payload.fileIndex; }, updateImages: (state, action) => { if (!state.project.image_list) { @@ -168,13 +178,13 @@ export const EditorSlice = createSlice({ if (!state.project.image_list) { state.project.image_list = []; } - state.loading="success" + state.loading = "success"; if (state.openFiles.flat().length === 0) { - const firstPanelIndex = 0 + const firstPanelIndex = 0; if (state.project.project_type === "html") { - state.openFiles[firstPanelIndex].push("index.html") + state.openFiles[firstPanelIndex].push("index.html"); } else { - state.openFiles[firstPanelIndex].push("main.py") + state.openFiles[firstPanelIndex].push("main.py"); } } state.justLoaded = true; @@ -217,9 +227,11 @@ export const EditorSlice = createSlice({ state.project.components[key].name = name; state.project.components[key].extension = extension; if (state.openFiles.flat().includes(oldName)) { - const panelIndex = state.openFiles.map((fileNames) => fileNames.includes(oldName)).indexOf(true) - const fileIndex = state.openFiles[panelIndex].indexOf(oldName) - state.openFiles[panelIndex][fileIndex] = `${name}.${extension}` + const panelIndex = state.openFiles + .map((fileNames) => fileNames.includes(oldName)) + .indexOf(true); + const fileIndex = state.openFiles[panelIndex].indexOf(oldName); + state.openFiles[panelIndex][fileIndex] = `${name}.${extension}`; } state.saving = "idle"; }, @@ -352,7 +364,7 @@ export const EditorSlice = createSlice({ state.saving = "idle"; state.currentLoadingRequestId = undefined; if (state.openFiles.flat().length === 0) { - const firstPanelIndex = 0 + const firstPanelIndex = 0; if (state.project.project_type === "html") { state.openFiles[firstPanelIndex].push("index.html"); } else { diff --git a/src/components/Editor/EditorSlice.test.js b/src/components/Editor/EditorSlice.test.js index 4a7a6d040..a53c3b702 100644 --- a/src/components/Editor/EditorSlice.test.js +++ b/src/components/Editor/EditorSlice.test.js @@ -1,4 +1,10 @@ -import { createOrUpdateProject, createRemix, deleteProject, readProject, readProjectList } from '../../utils/apiCallHandler'; +import { + createOrUpdateProject, + createRemix, + deleteProject, + readProject, + readProjectList, +} from "../../utils/apiCallHandler"; import reducer, { syncProject, @@ -12,585 +18,677 @@ import reducer, { loadProjectList, } from "./EditorSlice"; -jest.mock('../../utils/apiCallHandler') +jest.mock("../../utils/apiCallHandler"); test("Action stopCodeRun sets codeRunStopped to true", () => { const previousState = { codeRunTriggered: true, - codeRunStopped: false + codeRunStopped: false, }; const expectedState = { codeRunTriggered: true, - codeRunStopped: true - } - expect(reducer(previousState, stopCodeRun())).toEqual(expectedState) -}) + codeRunStopped: true, + }; + expect(reducer(previousState, stopCodeRun())).toEqual(expectedState); +}); test("Showing rename modal sets file state and showing status", () => { const previousState = { renameFileModalShowing: false, modals: {}, - } + }; const expectedState = { renameFileModalShowing: true, modals: { renameFile: { - name: 'main', - ext: '.py', - fileKey: 0 - } + name: "main", + ext: ".py", + fileKey: 0, + }, }, - } - expect(reducer(previousState, showRenameFileModal({name: 'main', ext: '.py', fileKey: 0}))).toEqual(expectedState) -}) + }; + expect( + reducer( + previousState, + showRenameFileModal({ name: "main", ext: ".py", fileKey: 0 }), + ), + ).toEqual(expectedState); +}); test("closing rename modal updates showing status", () => { const previousState = { - nameError: 'some error', + nameError: "some error", renameFileModalShowing: true, modals: { renameFile: { - name: 'main', - ext: '.py', - fileKey: 0 - } - } - } + name: "main", + ext: ".py", + fileKey: 0, + }, + }, + }; const expectedState = { - nameError: '', + nameError: "", renameFileModalShowing: false, modals: { renameFile: { - name: 'main', - ext: '.py', - fileKey: 0 - } - } - } - expect(reducer(previousState, closeRenameFileModal())).toEqual(expectedState) -}) - -describe('When project has no identifier', () => { - const dispatch = jest.fn() + name: "main", + ext: ".py", + fileKey: 0, + }, + }, + }; + expect(reducer(previousState, closeRenameFileModal())).toEqual(expectedState); +}); + +describe("When project has no identifier", () => { + const dispatch = jest.fn(); const project = { - name: 'hello world', - project_type: 'python', + name: "hello world", + project_type: "python", components: [ { - name: 'main', - extension: 'py', - content: '# hello' - } - ] - } - const access_token = 'myToken' + name: "main", + extension: "py", + content: "# hello", + }, + ], + }; + const access_token = "myToken"; const initialState = { editor: { project: project, - saving: 'idle', + saving: "idle", }, auth: { - isLoadingUser: false - } - } + isLoadingUser: false, + }, + }; - let saveThunk - let saveAction + let saveThunk; + let saveAction; beforeEach(() => { - Date.now = jest.fn(() => 1669808953) - saveThunk= syncProject('save') - saveAction = saveThunk({ project, accessToken: access_token, autosave: false }) - }) - - test('Saving creates new project', async () => { - await saveAction(dispatch, () => initialState) - expect(createOrUpdateProject).toHaveBeenCalledWith(project, access_token) - }) - - test('Successfully creating project triggers fulfilled action', async () => { - createOrUpdateProject.mockImplementationOnce(() => Promise.resolve({ status: 200 })) - await saveAction(dispatch, () => initialState) - expect(dispatch.mock.calls[1][0].type).toBe('editor/saveProject/fulfilled') - }) - - test('The saveProject/fulfilled action sets saving to success and loaded to idle', async () => { - const returnedProject = {...project, identifier: 'auto-generated-identifier'} + Date.now = jest.fn(() => 1669808953); + saveThunk = syncProject("save"); + saveAction = saveThunk({ + project, + accessToken: access_token, + autosave: false, + }); + }); + + test("Saving creates new project", async () => { + await saveAction(dispatch, () => initialState); + expect(createOrUpdateProject).toHaveBeenCalledWith(project, access_token); + }); + + test("Successfully creating project triggers fulfilled action", async () => { + createOrUpdateProject.mockImplementationOnce(() => + Promise.resolve({ status: 200 }), + ); + await saveAction(dispatch, () => initialState); + expect(dispatch.mock.calls[1][0].type).toBe("editor/saveProject/fulfilled"); + }); + + test("The saveProject/fulfilled action sets saving to success and loaded to idle", async () => { + const returnedProject = { + ...project, + identifier: "auto-generated-identifier", + }; const expectedState = { project: returnedProject, - saving: 'success', + saving: "success", lastSavedTime: 1669808953, - loading: 'idle' - } + loading: "idle", + }; - expect(reducer(initialState.editor, saveThunk.fulfilled({ project: returnedProject }))).toEqual(expectedState) - }) + expect( + reducer( + initialState.editor, + saveThunk.fulfilled({ project: returnedProject }), + ), + ).toEqual(expectedState); + }); // TODO: Autosave state testing +}); -}) - -describe('When project has an identifier', () => { - const dispatch = jest.fn() +describe("When project has an identifier", () => { + const dispatch = jest.fn(); const project = { - name: 'hello world', - project_type: 'python', - identifier: 'my-project-identifier', + name: "hello world", + project_type: "python", + identifier: "my-project-identifier", components: [ { - name: 'main', - extension: 'py', - content: '# hello' - } + name: "main", + extension: "py", + content: "# hello", + }, ], - image_list: [] - } - const access_token = 'myToken' + image_list: [], + }; + const access_token = "myToken"; const initialState = { editor: { project: project, - saving: 'idle' + saving: "idle", }, auth: { - isLoadingUser: false - } - } + isLoadingUser: false, + }, + }; - let saveThunk - let saveAction + let saveThunk; + let saveAction; - let remixThunk - let remixAction + let remixThunk; + let remixAction; beforeEach(() => { - saveThunk= syncProject('save') - saveAction = saveThunk({ project, accessToken: access_token, autosave: false }) - remixThunk = syncProject('remix') - remixAction = remixThunk({ project, accessToken: access_token }) - }) - - test('Saving updates existing project', async () => { - await saveAction(dispatch, () => initialState) - expect(createOrUpdateProject).toHaveBeenCalledWith(project, access_token) - }) - - test('Successfully updating project triggers fulfilled action', async () => { - createOrUpdateProject.mockImplementationOnce(() => Promise.resolve({ status: 200 })) - await saveAction(dispatch, () => initialState) - expect(dispatch.mock.calls[1][0].type).toBe('editor/saveProject/fulfilled') - }) - - test('The saveProject/fulfilled action sets saving to success', async () => { + saveThunk = syncProject("save"); + saveAction = saveThunk({ + project, + accessToken: access_token, + autosave: false, + }); + remixThunk = syncProject("remix"); + remixAction = remixThunk({ project, accessToken: access_token }); + }); + + test("Saving updates existing project", async () => { + await saveAction(dispatch, () => initialState); + expect(createOrUpdateProject).toHaveBeenCalledWith(project, access_token); + }); + + test("Successfully updating project triggers fulfilled action", async () => { + createOrUpdateProject.mockImplementationOnce(() => + Promise.resolve({ status: 200 }), + ); + await saveAction(dispatch, () => initialState); + expect(dispatch.mock.calls[1][0].type).toBe("editor/saveProject/fulfilled"); + }); + + test("The saveProject/fulfilled action sets saving to success", async () => { const expectedState = { project: project, - saving: 'success' - } - - expect(reducer(initialState.editor, saveThunk.fulfilled({ project }))).toEqual(expectedState) - }) - - test('Remixing triggers createRemix API call', async () => { - await remixAction(dispatch, () => initialState) - expect(createRemix).toHaveBeenCalledWith(project, access_token) - }) - - test('Successfully remixing project triggers fulfilled action', async () => { - createRemix.mockImplementationOnce(() => Promise.resolve({ status: 200 })) - await remixAction(dispatch, () => initialState) - expect(dispatch.mock.calls[1][0].type).toBe('editor/remixProject/fulfilled') - }) - - test('The remixProject/fulfilled action sets saving, loading and lastSaveAutosave', async () => { + saving: "success", + }; + + expect( + reducer(initialState.editor, saveThunk.fulfilled({ project })), + ).toEqual(expectedState); + }); + + test("Remixing triggers createRemix API call", async () => { + await remixAction(dispatch, () => initialState); + expect(createRemix).toHaveBeenCalledWith(project, access_token); + }); + + test("Successfully remixing project triggers fulfilled action", async () => { + createRemix.mockImplementationOnce(() => Promise.resolve({ status: 200 })); + await remixAction(dispatch, () => initialState); + expect(dispatch.mock.calls[1][0].type).toBe( + "editor/remixProject/fulfilled", + ); + }); + + test("The remixProject/fulfilled action sets saving, loading and lastSaveAutosave", async () => { const expectedState = { project: project, - saving: 'success', - loading: 'idle', - lastSaveAutosave: false - } - - expect(reducer(initialState.editor, remixThunk.fulfilled({ project }))).toEqual(expectedState) - }) -}) - -describe('When renaming a project from the rename project modal', () => { - let project = { name: 'hello world' } - const access_token = 'myToken' + saving: "success", + loading: "idle", + lastSaveAutosave: false, + }; + + expect( + reducer(initialState.editor, remixThunk.fulfilled({ project })), + ).toEqual(expectedState); + }); +}); + +describe("When renaming a project from the rename project modal", () => { + let project = { name: "hello world" }; + const access_token = "myToken"; const initialState = { editor: { project: {}, - modals: {renameProject: project}, + modals: { renameProject: project }, renameProjectModalShowing: true, - projectListLoaded: 'success' + projectListLoaded: "success", }, - auth: {user: {access_token}} - } + auth: { user: { access_token } }, + }; - let saveThunk + let saveThunk; beforeEach(() => { - saveThunk= syncProject('save') - }) + saveThunk = syncProject("save"); + }); - test('The saveProject/fulfilled action closes rename project modal and reloads projects list', () => { + test("The saveProject/fulfilled action closes rename project modal and reloads projects list", () => { const expectedState = { project: {}, - saving: 'success', + saving: "success", modals: { renameProject: null }, renameProjectModalShowing: false, - projectListLoaded: 'idle' - } - expect(reducer(initialState.editor, saveThunk.fulfilled({ project }))).toEqual(expectedState) - }) -}) - -describe('When deleting a project', () => { - const dispatch = jest.fn() - let project = { identifier: 'my-amazing-project', name: 'hello world' } - const access_token = 'myToken' + projectListLoaded: "idle", + }; + expect( + reducer(initialState.editor, saveThunk.fulfilled({ project })), + ).toEqual(expectedState); + }); +}); + +describe("When deleting a project", () => { + const dispatch = jest.fn(); + let project = { identifier: "my-amazing-project", name: "hello world" }; + const access_token = "myToken"; const initialState = { editor: { project: {}, - modals: {deleteProject: project}, + modals: { deleteProject: project }, deleteProjectModalShowing: true, - projectListLoaded: 'success' + projectListLoaded: "success", }, - auth: {user: {access_token}} - } + auth: { user: { access_token } }, + }; - let deleteThunk - let deleteAction + let deleteThunk; + let deleteAction; beforeEach(() => { - deleteThunk = syncProject('delete') - deleteAction = deleteThunk({ identifier: project.identifier, accessToken: access_token }) - }) - - test('Deleting a project triggers deleteProject API call', async () => { - await deleteAction(dispatch, () => initialState) - expect(deleteProject).toHaveBeenCalledWith(project.identifier, access_token) - }) - - test('Successfully deleting project triggers fulfilled action', async () => { - deleteProject.mockImplementationOnce(() => Promise.resolve({ status: 200 })) - await deleteAction(dispatch, () => initialState) - expect(dispatch.mock.calls[1][0].type).toBe('editor/deleteProject/fulfilled') - }) - - test('The deleteProject/fulfilled action closes delete project modal and reloads projects list', () => { + deleteThunk = syncProject("delete"); + deleteAction = deleteThunk({ + identifier: project.identifier, + accessToken: access_token, + }); + }); + + test("Deleting a project triggers deleteProject API call", async () => { + await deleteAction(dispatch, () => initialState); + expect(deleteProject).toHaveBeenCalledWith( + project.identifier, + access_token, + ); + }); + + test("Successfully deleting project triggers fulfilled action", async () => { + deleteProject.mockImplementationOnce(() => + Promise.resolve({ status: 200 }), + ); + await deleteAction(dispatch, () => initialState); + expect(dispatch.mock.calls[1][0].type).toBe( + "editor/deleteProject/fulfilled", + ); + }); + + test("The deleteProject/fulfilled action closes delete project modal and reloads projects list", () => { const expectedState = { project: {}, modals: { deleteProject: null }, deleteProjectModalShowing: false, - projectListLoaded: 'idle' - } - expect(reducer(initialState.editor, deleteThunk.fulfilled({}))).toEqual(expectedState) - }) -}) - -const requestingAProject = function(project, projectFile) { - const dispatch = jest.fn() + projectListLoaded: "idle", + }; + expect(reducer(initialState.editor, deleteThunk.fulfilled({}))).toEqual( + expectedState, + ); + }); +}); + +const requestingAProject = function (project, projectFile) { + const dispatch = jest.fn(); const initialState = { editor: { project: {}, - loading: 'idle' + loading: "idle", }, auth: { - isLoadingUser: false - } - } + isLoadingUser: false, + }, + }; - let loadThunk - let loadAction + let loadThunk; + let loadAction; - let loadFulfilledAction - let loadRejectedAction + let loadFulfilledAction; + let loadRejectedAction; beforeEach(() => { - loadThunk = syncProject('load') - loadAction = loadThunk({ identifier: 'my-project-identifier', locale: 'ja-JP', accessToken: 'my_token' }) - - loadFulfilledAction = loadThunk.fulfilled({ project }) - loadFulfilledAction.meta.requestId='my_request_id' - loadRejectedAction = loadThunk.rejected() - loadRejectedAction.meta.requestId='my_request_id' - }) - - test('Reads project from database', async () => { - await loadAction(dispatch, () => initialState) - expect(readProject).toHaveBeenCalledWith('my-project-identifier', 'ja-JP', 'my_token') - }) - - test('If loading status pending, loading success updates status', () => { + loadThunk = syncProject("load"); + loadAction = loadThunk({ + identifier: "my-project-identifier", + locale: "ja-JP", + accessToken: "my_token", + }); + + loadFulfilledAction = loadThunk.fulfilled({ project }); + loadFulfilledAction.meta.requestId = "my_request_id"; + loadRejectedAction = loadThunk.rejected(); + loadRejectedAction.meta.requestId = "my_request_id"; + }); + + test("Reads project from database", async () => { + await loadAction(dispatch, () => initialState); + expect(readProject).toHaveBeenCalledWith( + "my-project-identifier", + "ja-JP", + "my_token", + ); + }); + + test("If loading status pending, loading success updates status", () => { const initialState = { openFiles: [[]], - loading: 'pending', - currentLoadingRequestId: 'my_request_id' - } + loading: "pending", + currentLoadingRequestId: "my_request_id", + }; const expectedState = { openFiles: [[projectFile]], - loading: 'success', + loading: "success", justLoaded: true, - saving: 'idle', + saving: "idle", project: project, currentLoadingRequestId: undefined, - } - expect(reducer(initialState, loadFulfilledAction)).toEqual(expectedState) - }) + }; + expect(reducer(initialState, loadFulfilledAction)).toEqual(expectedState); + }); - test('If not latest request, loading success does not update status', () => { + test("If not latest request, loading success does not update status", () => { const initialState = { - loading: 'pending', - currentLoadingRequestId: 'another_request_id' - } - expect(reducer(initialState, loadFulfilledAction)).toEqual(initialState) - }) + loading: "pending", + currentLoadingRequestId: "another_request_id", + }; + expect(reducer(initialState, loadFulfilledAction)).toEqual(initialState); + }); - test('If already rejected, loading success does not update status', () => { + test("If already rejected, loading success does not update status", () => { const initialState = { - loading: 'failed' - } - expect(reducer(initialState, syncProject('load').fulfilled())).toEqual(initialState) - }) - - test('If loading status pending, loading failure updates status', () => { + loading: "failed", + }; + expect(reducer(initialState, syncProject("load").fulfilled())).toEqual( + initialState, + ); + }); + + test("If loading status pending, loading failure updates status", () => { const initialState = { - loading: 'pending', - currentLoadingRequestId: 'my_request_id' - } + loading: "pending", + currentLoadingRequestId: "my_request_id", + }; const expectedState = { - loading: 'failed', - saving: 'idle', - currentLoadingRequestId: undefined - } - expect(reducer(initialState, loadRejectedAction)).toEqual(expectedState) - }) - - test('If not latest request, loading failure does not update status', () => { + loading: "failed", + saving: "idle", + currentLoadingRequestId: undefined, + }; + expect(reducer(initialState, loadRejectedAction)).toEqual(expectedState); + }); + + test("If not latest request, loading failure does not update status", () => { const initialState = { - loading: 'pending', - currentLoadingRequestId: 'another_request_id' - } - expect(reducer(initialState, loadRejectedAction)).toEqual(initialState) - }) + loading: "pending", + currentLoadingRequestId: "another_request_id", + }; + expect(reducer(initialState, loadRejectedAction)).toEqual(initialState); + }); - test('If already fulfilled, loading rejection does not update status', () => { + test("If already fulfilled, loading rejection does not update status", () => { const initialState = { - loading: 'success' - } - expect(reducer(initialState, loadThunk.rejected())).toEqual(initialState) - }) -} + loading: "success", + }; + expect(reducer(initialState, loadThunk.rejected())).toEqual(initialState); + }); +}; -describe('When requesting a python project', () => { +describe("When requesting a python project", () => { const project = { - name: 'hello world', - project_type: 'python', - identifier: 'my-project-identifier', + name: "hello world", + project_type: "python", + identifier: "my-project-identifier", components: [ { - name: 'main', - extension: 'py', - content: '# hello' - } + name: "main", + extension: "py", + content: "# hello", + }, ], - image_list: [] - } - requestingAProject(project, 'main.py') -}) + image_list: [], + }; + requestingAProject(project, "main.py"); +}); -describe('When requesting a HTML project', () => { +describe("When requesting a HTML project", () => { const project = { - name: 'hello html world', - project_type: 'html', - identifier: 'my-project-identifier', + name: "hello html world", + project_type: "html", + identifier: "my-project-identifier", components: [ { - name: 'index', - extension: 'html', - content: '# hello world' - } + name: "index", + extension: "html", + content: "# hello world", + }, ], - image_list: [] - } - requestingAProject(project, 'index.html') -}) - -describe('When requesting project list', () => { - const dispatch = jest.fn() - const projects = [ - { name: 'project1' }, - { name: 'project2' } - ] + image_list: [], + }; + requestingAProject(project, "index.html"); +}); + +describe("When requesting project list", () => { + const dispatch = jest.fn(); + const projects = [{ name: "project1" }, { name: "project2" }]; const initialState = { projectList: [], - projectListLoaded: 'pending', - projectIndexCurrentPage: 4 - } - let loadProjectListThunk + projectListLoaded: "pending", + projectIndexCurrentPage: 4, + }; + let loadProjectListThunk; beforeEach(() => { - loadProjectListThunk = loadProjectList({page: 12, accessToken: 'access_token'}) - }) - - test('Loading project list triggers loadProjectList API call', async () => { - await loadProjectListThunk(dispatch, () => initialState) - expect(readProjectList).toHaveBeenCalledWith(12, 'access_token') - }) - - test('Successfully loading project list triggers fulfilled action', async () => { - readProjectList.mockImplementationOnce(() => Promise.resolve({ status: 200, headers: {}})) - await loadProjectListThunk(dispatch, () => initialState) - expect(dispatch.mock.calls[1][0].type).toBe('editor/loadProjectList/fulfilled') - }) - - test('The loadProjectList/fulfilled action with projects returned sets the projectList and total pages', () => { + loadProjectListThunk = loadProjectList({ + page: 12, + accessToken: "access_token", + }); + }); + + test("Loading project list triggers loadProjectList API call", async () => { + await loadProjectListThunk(dispatch, () => initialState); + expect(readProjectList).toHaveBeenCalledWith(12, "access_token"); + }); + + test("Successfully loading project list triggers fulfilled action", async () => { + readProjectList.mockImplementationOnce(() => + Promise.resolve({ status: 200, headers: {} }), + ); + await loadProjectListThunk(dispatch, () => initialState); + expect(dispatch.mock.calls[1][0].type).toBe( + "editor/loadProjectList/fulfilled", + ); + }); + + test("The loadProjectList/fulfilled action with projects returned sets the projectList and total pages", () => { const expectedState = { projectList: projects, - projectListLoaded: 'success', + projectListLoaded: "success", projectIndexCurrentPage: 4, - projectIndexTotalPages: 12 - } - expect(reducer(initialState, loadProjectList.fulfilled({projects, page: 4, links: {last: {page: 12}}}))).toEqual(expectedState) - }) - - test('The loadProjectList/fulfilled action with no projects loads previous page', () => { + projectIndexTotalPages: 12, + }; + expect( + reducer( + initialState, + loadProjectList.fulfilled({ + projects, + page: 4, + links: { last: { page: 12 } }, + }), + ), + ).toEqual(expectedState); + }); + + test("The loadProjectList/fulfilled action with no projects loads previous page", () => { const expectedState = { projectList: [], - projectListLoaded: 'idle', - projectIndexCurrentPage: 3 - } - expect(reducer(initialState, loadProjectList.fulfilled({projects: [], page: 4}))).toEqual(expectedState) - }) - - test('The loadProjectList/fulfilled action with no projects on page 1 sets loading to success', () => { + projectListLoaded: "idle", + projectIndexCurrentPage: 3, + }; + expect( + reducer( + initialState, + loadProjectList.fulfilled({ projects: [], page: 4 }), + ), + ).toEqual(expectedState); + }); + + test("The loadProjectList/fulfilled action with no projects on page 1 sets loading to success", () => { const expectedState = { projectList: [], - projectListLoaded: 'success', + projectListLoaded: "success", projectIndexCurrentPage: 1, - projectIndexTotalPages: 1 - } - expect(reducer({...initialState, projectIndexCurrentPage: 1}, loadProjectList.fulfilled({projects: [], page: 1}))).toEqual(expectedState) - }) -}) - -describe('Opening files', () => { + projectIndexTotalPages: 1, + }; + expect( + reducer( + { ...initialState, projectIndexCurrentPage: 1 }, + loadProjectList.fulfilled({ projects: [], page: 1 }), + ), + ).toEqual(expectedState); + }); +}); + +describe("Opening files", () => { const initialState = { - openFiles: [['main.py', 'file1.py']], - focussedFileIndices: [0] - } + openFiles: [["main.py", "file1.py"]], + focussedFileIndices: [0], + }; - test('Opening unopened file adds it to openFiles and focusses that file', () => { + test("Opening unopened file adds it to openFiles and focusses that file", () => { const expectedState = { - openFiles: [['main.py', 'file1.py', 'file2.py']], - focussedFileIndices: [2] - } - expect(reducer(initialState, openFile('file2.py'))).toEqual(expectedState) - }) + openFiles: [["main.py", "file1.py", "file2.py"]], + focussedFileIndices: [2], + }; + expect(reducer(initialState, openFile("file2.py"))).toEqual(expectedState); + }); - test('Opening already open file focusses that file', () => { + test("Opening already open file focusses that file", () => { const expectedState = { - openFiles: [['main.py', 'file1.py']], - focussedFileIndices: [1] - } - expect(reducer(initialState, openFile('file1.py'))).toEqual(expectedState) - }) + openFiles: [["main.py", "file1.py"]], + focussedFileIndices: [1], + }; + expect(reducer(initialState, openFile("file1.py"))).toEqual(expectedState); + }); - test('Switching file focus', () => { + test("Switching file focus", () => { const expectedState = { - openFiles: [['main.py', 'file1.py']], - focussedFileIndices: [1] - } - expect(reducer(initialState, setFocussedFileIndex({panelIndex: 0, fileIndex: 1}))).toEqual(expectedState) - }) -}) - -describe('Closing files', () => { - test('Closing the last file when focussed transfers focus to the left', () => { + openFiles: [["main.py", "file1.py"]], + focussedFileIndices: [1], + }; + expect( + reducer( + initialState, + setFocussedFileIndex({ panelIndex: 0, fileIndex: 1 }), + ), + ).toEqual(expectedState); + }); +}); + +describe("Closing files", () => { + test("Closing the last file when focussed transfers focus to the left", () => { const initialState = { - openFiles: [['main.py', 'file1.py']], - focussedFileIndices: [1] - } + openFiles: [["main.py", "file1.py"]], + focussedFileIndices: [1], + }; const expectedState = { - openFiles: [['main.py']], - focussedFileIndices: [0] - } - expect(reducer(initialState, closeFile('file1.py'))).toEqual(expectedState) - }) + openFiles: [["main.py"]], + focussedFileIndices: [0], + }; + expect(reducer(initialState, closeFile("file1.py"))).toEqual(expectedState); + }); - test('Closing not the last file when focussed does not change focus', () => { + test("Closing not the last file when focussed does not change focus", () => { const initialState = { - openFiles: [['main.py', 'file1.py', 'file2.py']], - focussedFileIndices: [1] - } + openFiles: [["main.py", "file1.py", "file2.py"]], + focussedFileIndices: [1], + }; const expectedState = { - openFiles: [['main.py', 'file2.py']], - focussedFileIndices: [1] - } - expect(reducer(initialState, closeFile('file1.py'))).toEqual(expectedState) - }) + openFiles: [["main.py", "file2.py"]], + focussedFileIndices: [1], + }; + expect(reducer(initialState, closeFile("file1.py"))).toEqual(expectedState); + }); - test('Closing unfocussed file before file that is in focus keeps same file in focus', () => { + test("Closing unfocussed file before file that is in focus keeps same file in focus", () => { const initialState = { - openFiles: [['main.py', 'file1.py', 'file2.py', 'file3.py']], - focussedFileIndices: [2] - } + openFiles: [["main.py", "file1.py", "file2.py", "file3.py"]], + focussedFileIndices: [2], + }; const expectedState = { - openFiles: [['main.py', 'file2.py', 'file3.py']], - focussedFileIndices: [1] - } - expect(reducer(initialState, closeFile('file1.py'))).toEqual(expectedState) - }) + openFiles: [["main.py", "file2.py", "file3.py"]], + focussedFileIndices: [1], + }; + expect(reducer(initialState, closeFile("file1.py"))).toEqual(expectedState); + }); - test('Closing unfocussed file after file that is in focus keeps same file in focus', () => { + test("Closing unfocussed file after file that is in focus keeps same file in focus", () => { const initialState = { - openFiles: [['main.py', 'file1.py', 'file2.py', 'file3.py']], - focussedFileIndices: [1] - } + openFiles: [["main.py", "file1.py", "file2.py", "file3.py"]], + focussedFileIndices: [1], + }; const expectedState = { - openFiles: [['main.py', 'file1.py', 'file3.py']], - focussedFileIndices: [1] - } - expect(reducer(initialState, closeFile('file2.py'))).toEqual(expectedState) - }) -}) - -describe('Updating file name', () => { + openFiles: [["main.py", "file1.py", "file3.py"]], + focussedFileIndices: [1], + }; + expect(reducer(initialState, closeFile("file2.py"))).toEqual(expectedState); + }); +}); + +describe("Updating file name", () => { const initialState = { project: { components: [ - {name: 'file', extension: 'py' }, - {name: 'another_file', extension: 'py'} - ] + { name: "file", extension: "py" }, + { name: "another_file", extension: "py" }, + ], }, - openFiles: [['file.py']] - } + openFiles: [["file.py"]], + }; - test('If file is open updates name in project and openFiles and saves', () => { + test("If file is open updates name in project and openFiles and saves", () => { const expectedState = { project: { components: [ - {name: 'my_file', extension: 'py' }, - {name: 'another_file', extension: 'py'} - ] + { name: "my_file", extension: "py" }, + { name: "another_file", extension: "py" }, + ], }, - openFiles: [['my_file.py']], + openFiles: [["my_file.py"]], saving: "idle", - } - expect(reducer(initialState, updateComponentName({key: 0, name: 'my_file', extension: 'py'}))).toEqual(expectedState) - }) - - test('If file is closed updates name in project and saves', () => { + }; + expect( + reducer( + initialState, + updateComponentName({ key: 0, name: "my_file", extension: "py" }), + ), + ).toEqual(expectedState); + }); + + test("If file is closed updates name in project and saves", () => { const expectedState = { project: { components: [ - {name: 'file', extension: 'py' }, - {name: 'my_file', extension: 'py'} - ] + { name: "file", extension: "py" }, + { name: "my_file", extension: "py" }, + ], }, - openFiles: [['file.py']], + openFiles: [["file.py"]], saving: "idle", - } - expect(reducer(initialState, updateComponentName({key: 1, name: 'my_file', extension: 'py'}))).toEqual(expectedState) - }) -}) + }; + expect( + reducer( + initialState, + updateComponentName({ key: 1, name: "my_file", extension: "py" }), + ), + ).toEqual(expectedState); + }); +}); diff --git a/src/components/Editor/ErrorMessage/ErrorMessage.js b/src/components/Editor/ErrorMessage/ErrorMessage.js index f2efa986b..ee2d151c9 100644 --- a/src/components/Editor/ErrorMessage/ErrorMessage.js +++ b/src/components/Editor/ErrorMessage/ErrorMessage.js @@ -1,15 +1,15 @@ -import React, { useContext } from 'react' -import './ErrorMessage.scss' -import { useSelector } from 'react-redux' -import { SettingsContext } from '../../../settings'; +import React, { useContext } from "react"; +import "./ErrorMessage.scss"; +import { useSelector } from "react-redux"; +import { SettingsContext } from "../../../settings"; const ErrorMessage = () => { const error = useSelector((state) => state.editor.error); - const settings = useContext(SettingsContext) + const settings = useContext(SettingsContext); return error ? ( <div className={`error-message error-message--${settings.fontSize}`}> - <p className='error-message__content'>{ error }</p> + <p className="error-message__content">{error}</p> </div> ) : null; }; diff --git a/src/components/Editor/ErrorMessage/ErrorMessage.test.js b/src/components/Editor/ErrorMessage/ErrorMessage.test.js index c9444f7b7..e45d7ba60 100644 --- a/src/components/Editor/ErrorMessage/ErrorMessage.test.js +++ b/src/components/Editor/ErrorMessage/ErrorMessage.test.js @@ -1,35 +1,36 @@ -import configureStore from 'redux-mock-store' -import { Provider } from "react-redux" -import { SettingsContext } from "../../../settings" -import ErrorMessage from "./ErrorMessage" -import { render, screen } from '@testing-library/react' +import configureStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { SettingsContext } from "../../../settings"; +import ErrorMessage from "./ErrorMessage"; +import { render, screen } from "@testing-library/react"; -describe('When error is set', () => { - +describe("When error is set", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - error: 'Oops' - } - } + error: "Oops", + }, + }; const store = mockStore(initialState); render( <Provider store={store}> - <SettingsContext.Provider value={{ theme: 'dark', fontSize: 'myFontSize' }}> + <SettingsContext.Provider + value={{ theme: "dark", fontSize: "myFontSize" }} + > <ErrorMessage /> </SettingsContext.Provider> - </Provider> - ) - }) + </Provider>, + ); + }); - test('Error message displays', () => { - expect(screen.queryByText('Oops')).toBeInTheDocument() - }) + test("Error message displays", () => { + expect(screen.queryByText("Oops")).toBeInTheDocument(); + }); - test('Font size class is set correctly', () => { - const errorMessage = screen.queryByText('Oops').parentElement - expect(errorMessage).toHaveClass("error-message--myFontSize") - }) -}) + test("Font size class is set correctly", () => { + const errorMessage = screen.queryByText("Oops").parentElement; + expect(errorMessage).toHaveClass("error-message--myFontSize"); + }); +}); diff --git a/src/components/Editor/ErrorMessage/NameErrorMessage.js b/src/components/Editor/ErrorMessage/NameErrorMessage.js index e8de91a99..78663e0f1 100644 --- a/src/components/Editor/ErrorMessage/NameErrorMessage.js +++ b/src/components/Editor/ErrorMessage/NameErrorMessage.js @@ -1,12 +1,12 @@ -import './ErrorMessage.scss' -import { useSelector } from 'react-redux' +import "./ErrorMessage.scss"; +import { useSelector } from "react-redux"; const NameErrorMessage = () => { const error = useSelector((state) => state.editor.nameError); return error ? ( - <div className='error-message'> - <p className='error-message__content'>{ error }</p> + <div className="error-message"> + <p className="error-message__content">{error}</p> </div> ) : null; }; diff --git a/src/components/Editor/FontSizeSelector/FontSizeSelector.js b/src/components/Editor/FontSizeSelector/FontSizeSelector.js index d7858df12..af2e63632 100644 --- a/src/components/Editor/FontSizeSelector/FontSizeSelector.js +++ b/src/components/Editor/FontSizeSelector/FontSizeSelector.js @@ -2,46 +2,67 @@ import React from "react"; import { useCookies } from "react-cookie"; import { useTranslation } from "react-i18next"; import { FontIcon } from "../../../Icons"; -import './FontSizeSelector.scss' +import "./FontSizeSelector.scss"; -const COOKIE_PATHS = ['/', '/projects', '/python'] +const COOKIE_PATHS = ["/", "/projects", "/python"]; const FontSizeSelector = () => { - const [ cookies , setCookie, removeCookie] = useCookies(['fontSize']) - const fontSize = cookies.fontSize || "small" - const { t } = useTranslation() + const [cookies, setCookie, removeCookie] = useCookies(["fontSize"]); + const fontSize = cookies.fontSize || "small"; + const { t } = useTranslation(); const setFontSize = (fontSize) => { if (cookies.fontSize) { COOKIE_PATHS.forEach((path) => { - removeCookie('fontSize', {path}) - }) + removeCookie("fontSize", { path }); + }); } - setCookie('fontSize', fontSize, { path: '/' }) - } + setCookie("fontSize", fontSize, { path: "/" }); + }; return ( - <div className='font-size-selector'> - <div className='font-btn font-btn--small' onClick={() => setFontSize('small')}> - <button className={`font-btn__icon font-btn__icon--small ${fontSize==='small' ? 'font-btn__icon--active' : ''}`}> - <FontIcon size={15}/> + <div className="font-size-selector"> + <div + className="font-btn font-btn--small" + onClick={() => setFontSize("small")} + > + <button + className={`font-btn__icon font-btn__icon--small ${ + fontSize === "small" ? "font-btn__icon--active" : "" + }`} + > + <FontIcon size={15} /> </button> - <p>{t('header.settingsMenu.textSizeOptions.small')}</p> + <p>{t("header.settingsMenu.textSizeOptions.small")}</p> </div> - <div className='font-btn font-btn--medium' onClick={() => setFontSize('medium')}> - <button className={`font-btn__icon font-btn__icon--medium ${fontSize==='medium' ? 'font-btn__icon--active' : ''}`}> - <FontIcon size={23}/> + <div + className="font-btn font-btn--medium" + onClick={() => setFontSize("medium")} + > + <button + className={`font-btn__icon font-btn__icon--medium ${ + fontSize === "medium" ? "font-btn__icon--active" : "" + }`} + > + <FontIcon size={23} /> </button> - <p>{t('header.settingsMenu.textSizeOptions.medium')}</p> + <p>{t("header.settingsMenu.textSizeOptions.medium")}</p> </div> - <div className='font-btn font-btn--large' onClick={() => setFontSize('large')}> - <button className={`font-btn__icon font-btn__icon--large ${fontSize==='large' ? 'font-btn__icon--active' : ''}`}> - <FontIcon size={36}/> + <div + className="font-btn font-btn--large" + onClick={() => setFontSize("large")} + > + <button + className={`font-btn__icon font-btn__icon--large ${ + fontSize === "large" ? "font-btn__icon--active" : "" + }`} + > + <FontIcon size={36} /> </button> - <p>{t('header.settingsMenu.textSizeOptions.large')}</p> + <p>{t("header.settingsMenu.textSizeOptions.large")}</p> </div> </div> - ) -} + ); +}; -export default FontSizeSelector +export default FontSizeSelector; diff --git a/src/components/Editor/FontSizeSelector/FontSizeSelector.test.js b/src/components/Editor/FontSizeSelector/FontSizeSelector.test.js index e7472ac1e..fc1db32c1 100644 --- a/src/components/Editor/FontSizeSelector/FontSizeSelector.test.js +++ b/src/components/Editor/FontSizeSelector/FontSizeSelector.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react" +import { render, fireEvent } from "@testing-library/react"; import FontSizeSelector from "./FontSizeSelector"; -import { Cookies, CookiesProvider } from 'react-cookie'; +import { Cookies, CookiesProvider } from "react-cookie"; describe("When font size cookie unset", () => { let cookies; @@ -12,33 +12,39 @@ describe("When font size cookie unset", () => { fontSelector = render( <CookiesProvider cookies={cookies}> <FontSizeSelector /> - </CookiesProvider> - ) - }) + </CookiesProvider>, + ); + }); - test('Cookie remains unset after render', () => { - expect(cookies.cookies.fontSize).toBeUndefined() - }) + test("Cookie remains unset after render", () => { + expect(cookies.cookies.fontSize).toBeUndefined(); + }); - test('Sets cookie to large when first button clicked', async () => { - const largeButton = fontSelector.getByText('header.settingsMenu.textSizeOptions.large').parentElement - fireEvent.click(largeButton) - expect(cookies.cookies.fontSize).toBe("large") - }) + test("Sets cookie to large when first button clicked", async () => { + const largeButton = fontSelector.getByText( + "header.settingsMenu.textSizeOptions.large", + ).parentElement; + fireEvent.click(largeButton); + expect(cookies.cookies.fontSize).toBe("large"); + }); - test('Sets cookie to medium when second button clicked', async () => { - const mediumButton = fontSelector.getByText('header.settingsMenu.textSizeOptions.medium').parentElement - fireEvent.click(mediumButton) - expect(cookies.cookies.fontSize).toBe("medium") - }) + test("Sets cookie to medium when second button clicked", async () => { + const mediumButton = fontSelector.getByText( + "header.settingsMenu.textSizeOptions.medium", + ).parentElement; + fireEvent.click(mediumButton); + expect(cookies.cookies.fontSize).toBe("medium"); + }); - test('Sets cookie to small when third button clicked', async () => { - const smallButton = fontSelector.getByText('header.settingsMenu.textSizeOptions.small').parentElement - fireEvent.click(smallButton) - expect(cookies.cookies.fontSize).toBe("small") - }) + test("Sets cookie to small when third button clicked", async () => { + const smallButton = fontSelector.getByText( + "header.settingsMenu.textSizeOptions.small", + ).parentElement; + fireEvent.click(smallButton); + expect(cookies.cookies.fontSize).toBe("small"); + }); afterEach(() => { - cookies.remove("theme") - }) -}) + cookies.remove("theme"); + }); +}); diff --git a/src/components/Editor/Hooks/useEmbeddedMode.js b/src/components/Editor/Hooks/useEmbeddedMode.js index 3bdbf4809..e3432577b 100644 --- a/src/components/Editor/Hooks/useEmbeddedMode.js +++ b/src/components/Editor/Hooks/useEmbeddedMode.js @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux' -import { setEmbedded } from '../EditorSlice' +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { setEmbedded } from "../EditorSlice"; export const useEmbeddedMode = (embed = false) => { const dispatch = useDispatch(); diff --git a/src/components/Editor/Hooks/useProject.js b/src/components/Editor/Hooks/useProject.js index 6ac28205f..602d054cc 100644 --- a/src/components/Editor/Hooks/useProject.js +++ b/src/components/Editor/Hooks/useProject.js @@ -9,7 +9,7 @@ export const useProject = (projectIdentifier = null, accessToken = null) => { const getCachedProject = (id) => JSON.parse(localStorage.getItem(id || "project")); const [cachedProject, setCachedProject] = useState( - getCachedProject(projectIdentifier) + getCachedProject(projectIdentifier), ); const { i18n } = useTranslation(); const dispatch = useDispatch(); @@ -40,7 +40,7 @@ export const useProject = (projectIdentifier = null, accessToken = null) => { identifier: projectIdentifier, locale: i18n.language, accessToken, - }) + }), ); return; } diff --git a/src/components/Editor/Hooks/useProject.test.js b/src/components/Editor/Hooks/useProject.test.js index 55ca440a8..d369457e1 100644 --- a/src/components/Editor/Hooks/useProject.test.js +++ b/src/components/Editor/Hooks/useProject.test.js @@ -55,7 +55,7 @@ test("If cached project does not match identifer does not use cached project", a localStorage.setItem("project", JSON.stringify(cachedProject)); renderHook(() => useProject("my-favourite-project")); await waitFor(() => - expect(setProject).not.toHaveBeenCalledWith(cachedProject) + expect(setProject).not.toHaveBeenCalledWith(cachedProject), ); }); @@ -68,7 +68,7 @@ test("If cached project does not match identifier loads correct uncached project identifier: project1.identifier, locale: "ja-JP", accessToken, - }) + }), ); }); @@ -80,7 +80,7 @@ test("If no cached project loads uncached project", async () => { identifier: "hello-world-project", locale: "ja-JP", accessToken, - }) + }), ); }); diff --git a/src/components/Editor/Hooks/useRequiresUser.js b/src/components/Editor/Hooks/useRequiresUser.js index ca7f99e97..088f7a672 100644 --- a/src/components/Editor/Hooks/useRequiresUser.js +++ b/src/components/Editor/Hooks/useRequiresUser.js @@ -1,18 +1,17 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom' +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; export const useRequiresUser = (isLoading, user) => { - const navigate = useNavigate() + const navigate = useNavigate(); useEffect(() => { - if(isLoading) { + if (isLoading) { return; } - if(!isLoading && !user) { - navigate('/') + if (!isLoading && !user) { + navigate("/"); } }, [isLoading, user]); }; - diff --git a/src/components/Editor/ImageUploadButton/ImageUploadButton.js b/src/components/Editor/ImageUploadButton/ImageUploadButton.js index ea5384d34..f3b69479f 100644 --- a/src/components/Editor/ImageUploadButton/ImageUploadButton.js +++ b/src/components/Editor/ImageUploadButton/ImageUploadButton.js @@ -1,139 +1,161 @@ -import './ImageUploadButton.css'; +import "./ImageUploadButton.css"; -import { useCallback, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux'; -import Dropzone from 'react-dropzone'; -import Modal from 'react-modal'; +import { useCallback, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Dropzone from "react-dropzone"; +import Modal from "react-modal"; -import { updateImages, setNameError } from '../EditorSlice'; -import Button from '../../Button/Button' -import NameErrorMessage from '../ErrorMessage/NameErrorMessage'; -import store from '../../../app/store'; -import { uploadImages } from '../../../utils/apiCallHandler'; +import { updateImages, setNameError } from "../EditorSlice"; +import Button from "../../Button/Button"; +import NameErrorMessage from "../ErrorMessage/NameErrorMessage"; +import store from "../../../app/store"; +import { uploadImages } from "../../../utils/apiCallHandler"; const allowedExtensions = { - "python": [ - "jpg", - "jpeg", - "png", - "gif" - ] -} + python: ["jpg", "jpeg", "png", "gif"], +}; const allowedExtensionsString = (projectType) => { const extensionsList = allowedExtensions[projectType]; if (extensionsList.length === 1) { - return `'.${extensionsList[0]}'` + return `'.${extensionsList[0]}'`; } else { - return `'.` + extensionsList.slice(0, -1).join(`', '.`) + `' or '.` + extensionsList[extensionsList.length - 1] + `'`; + return ( + `'.` + + extensionsList.slice(0, -1).join(`', '.`) + + `' or '.` + + extensionsList[extensionsList.length - 1] + + `'` + ); } -} +}; const ImageUploadButton = () => { const [modalIsOpen, setIsOpen] = useState(false); const [files, setFiles] = useState([]); const dispatch = useDispatch(); const projectType = useSelector((state) => state.editor.project.project_type); - const projectIdentifier = useSelector((state) => state.editor.project.identifier); + const projectIdentifier = useSelector( + (state) => state.editor.project.identifier, + ); const projectImages = useSelector((state) => state.editor.project.image_list); - const imageNames = projectImages.map(image => `${image.filename}`); + const imageNames = projectImages.map((image) => `${image.filename}`); const user = useSelector((state) => state.auth.user); const closeModal = () => { - setFiles([]) + setFiles([]); setIsOpen(false); - } + }; const showModal = () => { dispatch(setNameError("")); - setIsOpen(true) + setIsOpen(true); }; const saveImages = async () => { files.every((file) => { - const fileName = file.name - const extension = fileName.split('.').slice(1).join('.').toLowerCase(); - if (imageNames.includes(fileName) || files.filter(file => file.name === fileName).length > 1) { + const fileName = file.name; + const extension = fileName.split(".").slice(1).join(".").toLowerCase(); + if ( + imageNames.includes(fileName) || + files.filter((file) => file.name === fileName).length > 1 + ) { dispatch(setNameError("Image names must be unique.")); - return false - } - else if (isValidFileName(fileName, files)) { - return true + return false; + } else if (isValidFileName(fileName, files)) { + return true; } else if (!allowedExtensions[projectType].includes(extension)) { - dispatch(setNameError(`Image names must end in ${allowedExtensionsString(projectType)}.`)); - return false + dispatch( + setNameError( + `Image names must end in ${allowedExtensionsString(projectType)}.`, + ), + ); + return false; } else { dispatch(setNameError("Error")); - return false + return false; } - }) - if (store.getState().editor.nameError === '') { - const response = await uploadImages(projectIdentifier, user.access_token, files) - dispatch(updateImages(response.data.image_list)) - closeModal() + }); + if (store.getState().editor.nameError === "") { + const response = await uploadImages( + projectIdentifier, + user.access_token, + files, + ); + dispatch(updateImages(response.data.image_list)); + closeModal(); } - } + }; const isValidFileName = (fileName, files) => { - const extension = fileName.split('.').slice(1).join('.').toLowerCase() - if (allowedExtensions[projectType].includes(extension) && !imageNames.includes(fileName) && files.filter(file => file.name === fileName).length === 1) { + const extension = fileName.split(".").slice(1).join(".").toLowerCase(); + if ( + allowedExtensions[projectType].includes(extension) && + !imageNames.includes(fileName) && + files.filter((file) => file.name === fileName).length === 1 + ) { return true; } else { return false; } - } + }; const customStyles = { content: { - top: '50%', - left: '50%', - right: 'auto', - bottom: 'auto', - marginRight: '-50%', - transform: 'translate(-50%, -50%)', + top: "50%", + left: "50%", + right: "auto", + bottom: "auto", + marginRight: "-50%", + transform: "translate(-50%, -50%)", }, overlay: { - zIndex: 1000 - } + zIndex: 1000, + }, }; return ( <> - <Button buttonText='Upload Image' onClickHandler={showModal} className="proj-image-upload-button" /> + <Button + buttonText="Upload Image" + onClickHandler={showModal} + className="proj-image-upload-button" + /> <Modal isOpen={modalIsOpen} onRequestClose={closeModal} style={customStyles} contentLabel="Upload Image" - appElement={document.getElementById('root') || undefined} + appElement={document.getElementById("root") || undefined} > <h2>Upload an image</h2> <NameErrorMessage /> <Dropzone - onDrop={ - useCallback(acceptedFiles => { - setFiles(prev => [...prev, ...acceptedFiles]); - }, []) - }> + onDrop={useCallback((acceptedFiles) => { + setFiles((prev) => [...prev, ...acceptedFiles]); + }, [])} + > {({ getRootProps, getInputProps }) => ( <section> - <div {...getRootProps()} className='dropzone-area'> + <div {...getRootProps()} className="dropzone-area"> <input {...getInputProps()} /> - <p className='dropzone-info'>Drag and drop images here, or click to select images from file</p> - {files.map((file, i) => - <p key={i}>{file.name}</p>)} + <p className="dropzone-info"> + Drag and drop images here, or click to select images from file + </p> + {files.map((file, i) => ( + <p key={i}>{file.name}</p> + ))} </div> </section> )} </Dropzone> - <div className='modal-footer'> - <Button buttonText='Cancel' onClickHandler={closeModal} /> - <Button buttonText='Save' onClickHandler={saveImages} /> + <div className="modal-footer"> + <Button buttonText="Cancel" onClickHandler={closeModal} /> + <Button buttonText="Save" onClickHandler={saveImages} /> </div> - </Modal> </> ); -} +}; export default ImageUploadButton; diff --git a/src/components/Editor/ImageUploadButton/ImageUploadButton.test.js b/src/components/Editor/ImageUploadButton/ImageUploadButton.test.js index b121206b8..df575e530 100644 --- a/src/components/Editor/ImageUploadButton/ImageUploadButton.test.js +++ b/src/components/Editor/ImageUploadButton/ImageUploadButton.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { fireEvent, render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import ImageUploadButton from "./ImageUploadButton"; @@ -11,47 +11,53 @@ describe("When user logged in and owns project", () => { let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { name: "main", - extension: "py" - } + extension: "py", + }, ], image_list: [], project_type: "python", - user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" + user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", }, nameError: "", }, auth: { user: { profile: { - user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" - } - } - } - } + user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", + }, + }, + }, + }; store = mockStore(initialState); - ({ queryByText } = render(<Provider store={store}><div id='root'><ImageUploadButton /></div></Provider>)) + ({ queryByText } = render( + <Provider store={store}> + <div id="root"> + <ImageUploadButton /> + </div> + </Provider>, + )); button = queryByText(/Upload Image/); - }) + }); test("Modal opens when Image Upload button clicked", () => { - fireEvent.click(button) - const dropzone = queryByText(/Drag and drop/) - expect(dropzone).not.toBeNull() - }) + fireEvent.click(button); + const dropzone = queryByText(/Drag and drop/); + expect(dropzone).not.toBeNull(); + }); test("Modal closes when cancel button clicked", () => { - fireEvent.click(button) - const cancelButton = queryByText(/Cancel/) - fireEvent.click(cancelButton) - const dropzone = queryByText(/Drag and drop/) - expect(dropzone).toBeNull() - }) -}) + fireEvent.click(button); + const cancelButton = queryByText(/Cancel/); + fireEvent.click(cancelButton); + const dropzone = queryByText(/Drag and drop/); + expect(dropzone).toBeNull(); + }); +}); diff --git a/src/components/Editor/NewComponentButton/NewComponentButton.js b/src/components/Editor/NewComponentButton/NewComponentButton.js index 84c6de76f..c9e440fed 100644 --- a/src/components/Editor/NewComponentButton/NewComponentButton.js +++ b/src/components/Editor/NewComponentButton/NewComponentButton.js @@ -1,30 +1,30 @@ -import './NewComponentButton.scss'; +import "./NewComponentButton.scss"; -import {React} from 'react' -import { useDispatch } from 'react-redux'; +import { React } from "react"; +import { useDispatch } from "react-redux"; -import { showNewFileModal } from '../EditorSlice'; -import Button from '../../Button/Button' -import { PlusIcon } from '../../../Icons'; -import { useTranslation } from 'react-i18next'; +import { showNewFileModal } from "../EditorSlice"; +import Button from "../../Button/Button"; +import { PlusIcon } from "../../../Icons"; +import { useTranslation } from "react-i18next"; const NewComponentButton = () => { - const { t } = useTranslation() - const dispatch = useDispatch(); + const { t } = useTranslation(); + const dispatch = useDispatch(); - const openNewFileModal = () => { - dispatch(showNewFileModal()) - } + const openNewFileModal = () => { + dispatch(showNewFileModal()); + }; - return ( - <Button - buttonText={t('filePane.newFileButton')} - ButtonIcon={PlusIcon} - buttonOuter - onClickHandler={openNewFileModal} - className="btn--primary btn--small proj-new-component-button" - /> - ); - } + return ( + <Button + buttonText={t("filePane.newFileButton")} + ButtonIcon={PlusIcon} + buttonOuter + onClickHandler={openNewFileModal} + className="btn--primary btn--small proj-new-component-button" + /> + ); +}; export default NewComponentButton; diff --git a/src/components/Editor/NewComponentButton/NewComponentButton.test.js b/src/components/Editor/NewComponentButton/NewComponentButton.test.js index 6c5f11691..7ba970ed4 100644 --- a/src/components/Editor/NewComponentButton/NewComponentButton.test.js +++ b/src/components/Editor/NewComponentButton/NewComponentButton.test.js @@ -1,43 +1,47 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import NewComponentButton from "./NewComponentButton"; -import { showNewFileModal } from "../EditorSlice" +import { showNewFileModal } from "../EditorSlice"; describe("Testing the new file modal", () => { let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { name: "main", - extension: "py" - } + extension: "py", + }, ], - project_type: "python" + project_type: "python", }, nameError: "", - } - } + }, + }; store = mockStore(initialState); - render(<Provider store={store}><NewComponentButton /></Provider>) - }) + render( + <Provider store={store}> + <NewComponentButton /> + </Provider>, + ); + }); - test('Button renders', () => { - expect(screen.queryByText('filePane.newFileButton')).toBeInTheDocument() - }) + test("Button renders", () => { + expect(screen.queryByText("filePane.newFileButton")).toBeInTheDocument(); + }); - test('Clicking button opens new file modal', () => { - const button = screen.queryByText('filePane.newFileButton') - fireEvent.click(button) - const expectedActions = [showNewFileModal()] - expect(store.getActions()).toEqual(expectedActions) - }) -}) + test("Clicking button opens new file modal", () => { + const button = screen.queryByText("filePane.newFileButton"); + fireEvent.click(button); + const expectedActions = [showNewFileModal()]; + expect(store.getActions()).toEqual(expectedActions); + }); +}); diff --git a/src/components/Editor/NewInputPanelButton/NewInputPanelButton.js b/src/components/Editor/NewInputPanelButton/NewInputPanelButton.js index ffd73b731..3b9b828c1 100644 --- a/src/components/Editor/NewInputPanelButton/NewInputPanelButton.js +++ b/src/components/Editor/NewInputPanelButton/NewInputPanelButton.js @@ -4,14 +4,18 @@ import { useDispatch } from "react-redux"; import { addFilePanel } from "../EditorSlice"; const NewInputPanelButton = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const openNewPanel = () => { - dispatch(addFilePanel()) - } + dispatch(addFilePanel()); + }; return ( - <Button className={'btn--primary'} buttonText='Add another panel' onClickHandler={openNewPanel} /> - ) -} + <Button + className={"btn--primary"} + buttonText="Add another panel" + onClickHandler={openNewPanel} + /> + ); +}; -export default NewInputPanelButton +export default NewInputPanelButton; diff --git a/src/components/Editor/Output/Output.js b/src/components/Editor/Output/Output.js index eb7fc830e..3df4eeeb2 100644 --- a/src/components/Editor/Output/Output.js +++ b/src/components/Editor/Output/Output.js @@ -6,18 +6,16 @@ import RunBar from "../../RunButton/RunBar"; const Output = () => { const project = useSelector((state) => state.editor.project); - const isEmbedded = useSelector((state) => state.editor.isEmbedded) + const isEmbedded = useSelector((state) => state.editor.isEmbedded); return ( <> <ExternalFiles /> - <div className='proj-runner-container'> + <div className="proj-runner-container"> <RunnerFactory projectType={project.project_type} /> - { - isEmbedded ? <RunBar /> : null - } + {isEmbedded ? <RunBar /> : null} </div> </> - ) -} + ); +}; -export default Output +export default Output; diff --git a/src/components/Editor/Output/Output.test.js b/src/components/Editor/Output/Output.test.js index f2555aa34..952923c04 100644 --- a/src/components/Editor/Output/Output.test.js +++ b/src/components/Editor/Output/Output.test.js @@ -1,21 +1,25 @@ import React from "react"; -import { render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import Output from "./Output"; test("Component renders", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: { - components: [] - } - } - } - const store = mockStore(initialState); - const {container} = render(<Provider store={store}><Output/></Provider>) + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: { + components: [], + }, + }, + }; + const store = mockStore(initialState); + const { container } = render( + <Provider store={store}> + <Output /> + </Provider>, + ); expect(container.lastChild).toHaveClass("proj-runner-container"); -}) +}); diff --git a/src/components/Editor/Project/Project.js b/src/components/Editor/Project/Project.js index 09161239d..7751cdffb 100644 --- a/src/components/Editor/Project/Project.js +++ b/src/components/Editor/Project/Project.js @@ -1,129 +1,172 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { useEffect, useState, useMemo } from 'react'; -import { useDispatch, useSelector} from 'react-redux' -import 'react-tabs/style/react-tabs.css' -import 'react-toastify/dist/ReactToastify.css' -import { useContainerQuery } from 'react-container-query'; -import classnames from 'classnames'; +import React, { useEffect, useState, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import "react-tabs/style/react-tabs.css"; +import "react-toastify/dist/ReactToastify.css"; +import { useContainerQuery } from "react-container-query"; +import classnames from "classnames"; -import './Project.scss'; -import Output from '../Output/Output' -import RenameFile from '../../Modals/RenameFile' -import { expireJustLoaded, setHasShownSavePrompt, setFocussedFileIndex, syncProject, openFile } from '../EditorSlice'; -import { isOwner } from '../../../utils/projectHelpers' -import NotFoundModal from '../../Modals/NotFoundModal'; -import AccessDeniedNoAuthModal from '../../Modals/AccessDeniedNoAuthModal'; -import AccessDeniedWithAuthModal from '../../Modals/AccessDeniedWithAuthModal'; -import { showLoginPrompt, showSavedMessage, showSavePrompt } from '../../../utils/Notifications'; -import SideMenu from '../../Menus/SideMenu/SideMenu'; -import EditorInput from '../EditorInput/EditorInput'; -import NewFileModal from '../../Modals/NewFileModal'; -import ResizableWithHandle from '../../../utils/ResizableWithHandle'; -import { projContainer } from '../../../utils/containerQueries'; +import "./Project.scss"; +import Output from "../Output/Output"; +import RenameFile from "../../Modals/RenameFile"; +import { + expireJustLoaded, + setHasShownSavePrompt, + setFocussedFileIndex, + syncProject, + openFile, +} from "../EditorSlice"; +import { isOwner } from "../../../utils/projectHelpers"; +import NotFoundModal from "../../Modals/NotFoundModal"; +import AccessDeniedNoAuthModal from "../../Modals/AccessDeniedNoAuthModal"; +import AccessDeniedWithAuthModal from "../../Modals/AccessDeniedWithAuthModal"; +import { + showLoginPrompt, + showSavedMessage, + showSavePrompt, +} from "../../../utils/Notifications"; +import SideMenu from "../../Menus/SideMenu/SideMenu"; +import EditorInput from "../EditorInput/EditorInput"; +import NewFileModal from "../../Modals/NewFileModal"; +import ResizableWithHandle from "../../../utils/ResizableWithHandle"; +import { projContainer } from "../../../utils/containerQueries"; const Project = (props) => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { forWebComponent } = props; - const user = useSelector((state) => state.auth.user) - const project = useSelector((state) => state.editor.project) - const modals = useSelector((state) => state.editor.modals) - const newFileModalShowing = useSelector((state) => state.editor.newFileModalShowing) - const renameFileModalShowing = useSelector((state) => state.editor.renameFileModalShowing) - const notFoundModalShowing = useSelector((state) => state.editor.notFoundModalShowing) - const accessDeniedNoAuthModalShowing = useSelector((state) => state.editor.accessDeniedNoAuthModalShowing) - const accessDeniedWithAuthModalShowing = useSelector((state) => state.editor.accessDeniedWithAuthModalShowing) - const justLoaded = useSelector((state) => state.editor.justLoaded) - const hasShownSavePrompt = useSelector((state) => state.editor.hasShownSavePrompt) - const openFiles = useSelector((state) => state.editor.openFiles) - const saving = useSelector((state) => state.editor.saving) - const autosave = useSelector((state) => state.editor.lastSaveAutosave) + const user = useSelector((state) => state.auth.user); + const project = useSelector((state) => state.editor.project); + const modals = useSelector((state) => state.editor.modals); + const newFileModalShowing = useSelector( + (state) => state.editor.newFileModalShowing, + ); + const renameFileModalShowing = useSelector( + (state) => state.editor.renameFileModalShowing, + ); + const notFoundModalShowing = useSelector( + (state) => state.editor.notFoundModalShowing, + ); + const accessDeniedNoAuthModalShowing = useSelector( + (state) => state.editor.accessDeniedNoAuthModalShowing, + ); + const accessDeniedWithAuthModalShowing = useSelector( + (state) => state.editor.accessDeniedWithAuthModalShowing, + ); + const justLoaded = useSelector((state) => state.editor.justLoaded); + const hasShownSavePrompt = useSelector( + (state) => state.editor.hasShownSavePrompt, + ); + const openFiles = useSelector((state) => state.editor.openFiles); + const saving = useSelector((state) => state.editor.saving); + const autosave = useSelector((state) => state.editor.lastSaveAutosave); useEffect(() => { - if (saving === 'success' && autosave === false) { - showSavedMessage() + if (saving === "success" && autosave === false) { + showSavedMessage(); } - }, [saving, autosave]) + }, [saving, autosave]); const switchToFileTab = (panelIndex, fileIndex) => { - dispatch(setFocussedFileIndex({panelIndex, fileIndex})) - } + dispatch(setFocussedFileIndex({ panelIndex, fileIndex })); + }; const openFileTab = (fileName) => { if (openFiles.flat().includes(fileName)) { - const panelIndex = openFiles.map((fileNames) => ( - fileNames.includes(fileName) - )).indexOf(true) - const fileIndex = openFiles[panelIndex].indexOf(fileName) - switchToFileTab(panelIndex, fileIndex) + const panelIndex = openFiles + .map((fileNames) => fileNames.includes(fileName)) + .indexOf(true); + const fileIndex = openFiles[panelIndex].indexOf(fileName); + switchToFileTab(panelIndex, fileIndex); } else { - dispatch(openFile(fileName)) - switchToFileTab(0, openFiles[0].length) + dispatch(openFile(fileName)); + switchToFileTab(0, openFiles[0].length); } - } + }; useEffect(() => { if (forWebComponent) { - return + return; } - if (user && localStorage.getItem('awaitingSave')) { + if (user && localStorage.getItem("awaitingSave")) { if (isOwner(user, project)) { - dispatch(syncProject('save')({project, accessToken: user.access_token, autosave: false})) + dispatch( + syncProject("save")({ + project, + accessToken: user.access_token, + autosave: false, + }), + ); } else if (user && project.identifier) { - dispatch(syncProject('remix')({project, accessToken: user.access_token})) + dispatch( + syncProject("remix")({ project, accessToken: user.access_token }), + ); } - localStorage.removeItem('awaitingSave') - return + localStorage.removeItem("awaitingSave"); + return; } let debouncer = setTimeout(() => { if (isOwner(user, project) && project.identifier) { if (justLoaded) { - dispatch(expireJustLoaded()) + dispatch(expireJustLoaded()); } - dispatch(syncProject('save')({ project, accessToken: user.access_token, autosave: true })); - } - else { + dispatch( + syncProject("save")({ + project, + accessToken: user.access_token, + autosave: true, + }), + ); + } else { if (justLoaded) { - dispatch(expireJustLoaded()) + dispatch(expireJustLoaded()); } else { - localStorage.setItem(project.identifier || 'project', JSON.stringify(project)) + localStorage.setItem( + project.identifier || "project", + JSON.stringify(project), + ); if (!hasShownSavePrompt) { - user ? showSavePrompt() : showLoginPrompt() - dispatch(setHasShownSavePrompt()) + user ? showSavePrompt() : showLoginPrompt(); + dispatch(setHasShownSavePrompt()); } } } }, 2000); - return () => clearTimeout(debouncer) - }, [dispatch, forWebComponent, project, user]) + return () => clearTimeout(debouncer); + }, [dispatch, forWebComponent, project, user]); const [params, containerRef] = useContainerQuery(projContainer); - const [defaultWidth, setDefaultWidth] = useState('auto'); - const [defaultHeight, setDefaultHeight] = useState('auto'); - const [maxWidth, setMaxWidth] = useState('100%'); - const [handleDirection, setHandleDirection] = useState('right'); + const [defaultWidth, setDefaultWidth] = useState("auto"); + const [defaultHeight, setDefaultHeight] = useState("auto"); + const [maxWidth, setMaxWidth] = useState("100%"); + const [handleDirection, setHandleDirection] = useState("right"); useMemo(() => { - const isDesktop = params['width-larger-than-880']; + const isDesktop = params["width-larger-than-880"]; - setDefaultWidth(isDesktop ? '50%' : '100%'); - setDefaultHeight(isDesktop ? '100%' : '50%'); - setMaxWidth(isDesktop ? '75%' : '100%'); - setHandleDirection(isDesktop ? 'right' : 'bottom'); + setDefaultWidth(isDesktop ? "50%" : "100%"); + setDefaultHeight(isDesktop ? "100%" : "50%"); + setMaxWidth(isDesktop ? "75%" : "100%"); + setHandleDirection(isDesktop ? "right" : "bottom"); }, [params]); return ( - <div className='proj'> - <div ref={containerRef} className={classnames('proj-container', {'proj-container--wc': forWebComponent})}> - {!forWebComponent ? <SideMenu openFileTab={openFileTab}/> : null} - <div className='proj-editor-wrapper'> + <div className="proj"> + <div + ref={containerRef} + className={classnames("proj-container", { + "proj-container--wc": forWebComponent, + })} + > + {!forWebComponent ? <SideMenu openFileTab={openFileTab} /> : null} + <div className="proj-editor-wrapper"> <ResizableWithHandle - data-testid='proj-editor-container' - className='proj-editor-container' + data-testid="proj-editor-container" + className="proj-editor-container" defaultWidth={defaultWidth} defaultHeight={defaultHeight} handleDirection={handleDirection} - minWidth='25%' + minWidth="25%" maxWidth={maxWidth} > <EditorInput /> @@ -131,13 +174,13 @@ const Project = (props) => { <Output /> </div> </div> - {(newFileModalShowing) ? <NewFileModal /> : null} - {(renameFileModalShowing && modals.renameFile) ? <RenameFile /> : null} - {(notFoundModalShowing) ? <NotFoundModal /> : null} - {(accessDeniedNoAuthModalShowing) ? <AccessDeniedNoAuthModal /> : null} - {(accessDeniedWithAuthModalShowing) ? <AccessDeniedWithAuthModal /> : null} + {newFileModalShowing ? <NewFileModal /> : null} + {renameFileModalShowing && modals.renameFile ? <RenameFile /> : null} + {notFoundModalShowing ? <NotFoundModal /> : null} + {accessDeniedNoAuthModalShowing ? <AccessDeniedNoAuthModal /> : null} + {accessDeniedWithAuthModalShowing ? <AccessDeniedWithAuthModal /> : null} </div> - ) + ); }; export default Project; diff --git a/src/components/Editor/Project/Project.test.js b/src/components/Editor/Project/Project.test.js index b3d8d63d2..eb5def959 100644 --- a/src/components/Editor/Project/Project.test.js +++ b/src/components/Editor/Project/Project.test.js @@ -1,452 +1,605 @@ import React from "react"; -import { render, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import Project from "./Project"; -import { expireJustLoaded, setHasShownSavePrompt, syncProject } from "../EditorSlice"; -import { showLoginPrompt, showSavedMessage, showSavePrompt } from "../../../utils/Notifications"; - -window.HTMLElement.prototype.scrollIntoView = jest.fn() - -jest.mock('axios'); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => jest.fn() +import { + expireJustLoaded, + setHasShownSavePrompt, + syncProject, +} from "../EditorSlice"; +import { + showLoginPrompt, + showSavedMessage, + showSavePrompt, +} from "../../../utils/Notifications"; + +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +jest.mock("axios"); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => jest.fn(), })); -jest.mock('../EditorSlice', () => ({ - ...jest.requireActual('../EditorSlice'), - syncProject: jest.fn((_) => jest.fn()) -})) +jest.mock("../EditorSlice", () => ({ + ...jest.requireActual("../EditorSlice"), + syncProject: jest.fn((_) => jest.fn()), +})); -jest.mock('../../../utils/Notifications') +jest.mock("../../../utils/Notifications"); -jest.useFakeTimers() +jest.useFakeTimers(); const user1 = { - access_token: 'myAccessToken', + access_token: "myAccessToken", profile: { - user: 'b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf' - } -} + user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", + }, +}; const user2 = { - access_token: 'myAccessToken', + access_token: "myAccessToken", profile: { - user: 'cd8a5b3d-f7bb-425e-908f-1386decd6bb1' - } -} + user: "cd8a5b3d-f7bb-425e-908f-1386decd6bb1", + }, +}; const project = { - name: 'hello world', - project_type: 'python', - identifier: 'hello-world-project', - components: [ - { - name: 'main', - extension: 'py', - content: '# hello' - } - ], - user_id: user1.profile.user -} + name: "hello world", + project_type: "python", + identifier: "hello-world-project", + components: [ + { + name: "main", + extension: "py", + content: "# hello", + }, + ], + user_id: user1.profile.user, +}; test("Renders with file menu if not for web component", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - components: [] + components: [], }, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - const {queryByText} = render(<Provider store={store}><div id="app"><Project/></div></Provider>) - expect(queryByText('filePane.files')).not.toBeNull() -}) + const { queryByText } = render( + <Provider store={store}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + expect(queryByText("filePane.files")).not.toBeNull(); +}); test("Renders without file menu if for web component", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - components: [] + components: [], }, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - const {queryByText} = render(<Provider store={store}><Project forWebComponent={true}/></Provider>) - expect(queryByText('filePane.files')).toBeNull() -}) - -describe('When not logged in and just loaded', () => { - let mockedStore + const { queryByText } = render( + <Provider store={store}> + <Project forWebComponent={true} /> + </Provider>, + ); + expect(queryByText("filePane.files")).toBeNull(); +}); + +describe("When not logged in and just loaded", () => { + let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - loading: 'success', + loading: "success", justLoaded: true, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, - auth: {} - } + auth: {}, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) - - test('Expires justLoaded', async () => { - await waitFor(() => expect(mockedStore.getActions()).toEqual([expireJustLoaded()]), {timeout: 2100}) - }) -}) - -describe('When not logged in and not just loaded', () => { - let mockedStore + localStorage.clear(); + }); + + test("Expires justLoaded", async () => { + await waitFor( + () => expect(mockedStore.getActions()).toEqual([expireJustLoaded()]), + { timeout: 2100 }, + ); + }); +}); + +describe("When not logged in and not just loaded", () => { + let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - loading: 'success', + loading: "success", justLoaded: false, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, - auth: {} - } + auth: {}, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) + localStorage.clear(); + }); - test('Login prompt shown', async () => { - await waitFor(() => expect(showLoginPrompt).toHaveBeenCalled(), {timeout: 2100}) - }) - - test('Dispatches save prompt shown action', async () => { - await waitFor(() => expect(mockedStore.getActions()).toEqual([setHasShownSavePrompt()]), {timeout: 2100}) - }) - - test('Project saved in localStorage', async () => { - await waitFor(() => expect(localStorage.getItem('hello-world-project')).toEqual(JSON.stringify(project)), {timeout: 2100}) - }) -}) - -describe('When not logged in and has been prompted to login to save', () => { - let mockedStore + test("Login prompt shown", async () => { + await waitFor(() => expect(showLoginPrompt).toHaveBeenCalled(), { + timeout: 2100, + }); + }); + + test("Dispatches save prompt shown action", async () => { + await waitFor( + () => expect(mockedStore.getActions()).toEqual([setHasShownSavePrompt()]), + { timeout: 2100 }, + ); + }); + + test("Project saved in localStorage", async () => { + await waitFor( + () => + expect(localStorage.getItem("hello-world-project")).toEqual( + JSON.stringify(project), + ), + { timeout: 2100 }, + ); + }); +}); + +describe("When not logged in and has been prompted to login to save", () => { + let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - loading: 'success', + loading: "success", justLoaded: false, hasShownSavePrompt: true, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, - auth: {} - } + auth: {}, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) - - test('Login prompt shown', async () => { - jest.runAllTimers() - await waitFor(() => expect(showLoginPrompt).not.toHaveBeenCalled(), {timeout: 2100}) - }) + localStorage.clear(); + }); - test('Project saved in localStorage', async () => { - await waitFor(() => expect(localStorage.getItem('hello-world-project')).toEqual(JSON.stringify(project)), {timeout: 2100}) - }) -}) - -describe('When logged in and user does not own project and just loaded', () => { - let mockedStore + test("Login prompt shown", async () => { + jest.runAllTimers(); + await waitFor(() => expect(showLoginPrompt).not.toHaveBeenCalled(), { + timeout: 2100, + }); + }); + + test("Project saved in localStorage", async () => { + await waitFor( + () => + expect(localStorage.getItem("hello-world-project")).toEqual( + JSON.stringify(project), + ), + { timeout: 2100 }, + ); + }); +}); + +describe("When logged in and user does not own project and just loaded", () => { + let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project, - loading: 'success', + loading: "success", justLoaded: true, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, auth: { - user: user2 - } - } + user: user2, + }, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) - - test('Expires justLoaded', async () => { - await waitFor(() => expect(mockedStore.getActions()).toEqual([expireJustLoaded()]), {timeout: 2100}) - }) -}) - -describe('When logged in and user does not own project and not just loaded', () => { - let mockedStore + localStorage.clear(); + }); + + test("Expires justLoaded", async () => { + await waitFor( + () => expect(mockedStore.getActions()).toEqual([expireJustLoaded()]), + { timeout: 2100 }, + ); + }); +}); + +describe("When logged in and user does not own project and not just loaded", () => { + let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project, - loading: 'success', + loading: "success", justLoaded: false, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, auth: { - user: user2 - } - } + user: user2, + }, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) - - test('Save prompt shown', async () => { - await waitFor(() => expect(showSavePrompt).toHaveBeenCalled(), {timeout: 2100}) - }) - - test('Dispatches save prompt shown action', async () => { - await waitFor(() => expect(mockedStore.getActions()).toEqual([setHasShownSavePrompt()]), {timeout: 2100}) - }) - - test('Project saved in localStorage', async () => { - await waitFor(() => expect(localStorage.getItem('hello-world-project')).toEqual(JSON.stringify(project)), {timeout: 2100}) - }) -}) + localStorage.clear(); + }); -describe('When logged in and user does not own project and prompted to save', () => { - let mockedStore + test("Save prompt shown", async () => { + await waitFor(() => expect(showSavePrompt).toHaveBeenCalled(), { + timeout: 2100, + }); + }); + + test("Dispatches save prompt shown action", async () => { + await waitFor( + () => expect(mockedStore.getActions()).toEqual([setHasShownSavePrompt()]), + { timeout: 2100 }, + ); + }); + + test("Project saved in localStorage", async () => { + await waitFor( + () => + expect(localStorage.getItem("hello-world-project")).toEqual( + JSON.stringify(project), + ), + { timeout: 2100 }, + ); + }); +}); + +describe("When logged in and user does not own project and prompted to save", () => { + let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project, - loading: 'success', + loading: "success", justLoaded: false, hasShownSavePrompt: true, openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, auth: { - user: user2 - } - } + user: user2, + }, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) + localStorage.clear(); + }); - test('Save prompt not shown again', async () => { - jest.runAllTimers() - await waitFor(() => expect(showSavePrompt).not.toHaveBeenCalled(), {timeout: 2100}) - }) - - test('Project saved in localStorage', async () => { - await waitFor(() => expect(localStorage.getItem('hello-world-project')).toEqual(JSON.stringify(project)), {timeout: 2100}) - }) -}) - -describe('When logged in and user does not own project and awaiting save', () => { - let mockedStore - let remixProject - let remixAction + test("Save prompt not shown again", async () => { + jest.runAllTimers(); + await waitFor(() => expect(showSavePrompt).not.toHaveBeenCalled(), { + timeout: 2100, + }); + }); + + test("Project saved in localStorage", async () => { + await waitFor( + () => + expect(localStorage.getItem("hello-world-project")).toEqual( + JSON.stringify(project), + ), + { timeout: 2100 }, + ); + }); +}); + +describe("When logged in and user does not own project and awaiting save", () => { + let mockedStore; + let remixProject; + let remixAction; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project, - loading: 'success', + loading: "success", openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, auth: { - user: user2 - } - } + user: user2, + }, + }; mockedStore = mockStore(initialState); - localStorage.setItem('awaitingSave', 'true') - remixAction = {type: 'REMIX_PROJECT' } - remixProject = jest.fn(() => remixAction) - syncProject.mockImplementationOnce(jest.fn((_) => (remixProject))) - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + localStorage.setItem("awaitingSave", "true"); + remixAction = { type: "REMIX_PROJECT" }; + remixProject = jest.fn(() => remixAction); + syncProject.mockImplementationOnce(jest.fn((_) => remixProject)); + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) - - test('Project remixed and saved to database', async () => { - await waitFor(() => expect(remixProject).toHaveBeenCalledWith({project, accessToken: user2.access_token}), {timeout: 2100}) - expect(mockedStore.getActions()[0]).toEqual(remixAction) - }) -}) - -describe('When logged in and project has no identifier and awaiting save', () => { - let mockedStore - let saveProject - let saveAction + localStorage.clear(); + }); + + test("Project remixed and saved to database", async () => { + await waitFor( + () => + expect(remixProject).toHaveBeenCalledWith({ + project, + accessToken: user2.access_token, + }), + { timeout: 2100 }, + ); + expect(mockedStore.getActions()[0]).toEqual(remixAction); + }); +}); + +describe("When logged in and project has no identifier and awaiting save", () => { + let mockedStore; + let saveProject; + let saveAction; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - project: {...project, identifier: null}, - loading: 'success', + project: { ...project, identifier: null }, + loading: "success", openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, auth: { - user: user2 - } - } + user: user2, + }, + }; mockedStore = mockStore(initialState); - localStorage.setItem('awaitingSave', 'true') - saveAction = {type: 'SAVE_PROJECT' } - saveProject = jest.fn(() => saveAction) - syncProject.mockImplementationOnce(jest.fn((_) => (saveProject))) - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) + localStorage.setItem("awaitingSave", "true"); + saveAction = { type: "SAVE_PROJECT" }; + saveProject = jest.fn(() => saveAction); + syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); afterEach(() => { - localStorage.clear() - }) - - test('Project saved to database', async () => { - await waitFor(() => expect(saveProject).toHaveBeenCalledWith({project: {...project, identifier: null}, accessToken: user2.access_token, autosave: false}), {timeout: 2100}) - expect(mockedStore.getActions()[0]).toEqual(saveAction) - }) -}) - -describe('When logged in and user owns project', () => { - + localStorage.clear(); + }); + + test("Project saved to database", async () => { + await waitFor( + () => + expect(saveProject).toHaveBeenCalledWith({ + project: { ...project, identifier: null }, + accessToken: user2.access_token, + autosave: false, + }), + { timeout: 2100 }, + ); + expect(mockedStore.getActions()[0]).toEqual(saveAction); + }); +}); + +describe("When logged in and user owns project", () => { let mockedStore; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project, - loading: 'success', + loading: "success", openFiles: [[]], - focussedFileIndices: [0] + focussedFileIndices: [0], }, auth: { - user: user1 - } - } + user: user1, + }, + }; mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - }) - - test('Project autosaved to database', async () => { - const saveAction = {type: 'SAVE_PROJECT' } - const saveProject = jest.fn(() => saveAction) - syncProject.mockImplementationOnce(jest.fn((_) => (saveProject))) - await waitFor(() => expect(saveProject).toHaveBeenCalledWith({project, accessToken: user1.access_token, autosave: true}), {timeout: 2100}) - expect(mockedStore.getActions()[0]).toEqual(saveAction) - }) -}) - -test('Successful manual save prompts project saved message', async () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + }); + + test("Project autosaved to database", async () => { + const saveAction = { type: "SAVE_PROJECT" }; + const saveProject = jest.fn(() => saveAction); + syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + await waitFor( + () => + expect(saveProject).toHaveBeenCalledWith({ + project, + accessToken: user1.access_token, + autosave: true, + }), + { timeout: 2100 }, + ); + expect(mockedStore.getActions()[0]).toEqual(saveAction); + }); +}); + +test("Successful manual save prompts project saved message", async () => { + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - components: [] + components: [], }, openFiles: [[]], focussedFileIndices: [0], - saving: 'success', - lastSaveAutosave: false + saving: "success", + lastSaveAutosave: false, }, - auth: {} - } + auth: {}, + }; const mockedStore = mockStore(initialState); - render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - await waitFor(() => expect(showSavedMessage).toHaveBeenCalled()) -}) + render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + await waitFor(() => expect(showSavedMessage).toHaveBeenCalled()); +}); // TODO: Write test for successful autosave not prompting the project saved message as per the above -describe('When not logged in and falling on default container width', () => { +describe("When not logged in and falling on default container width", () => { test("Shows bottom drag bar with expected params", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - openFiles: [['main.py']], - focussedFileIndices: [0] + openFiles: [["main.py"]], + focussedFileIndices: [0], }, - auth: {} - } + auth: {}, + }; const mockedStore = mockStore(initialState); - const { getByTestId } = render(<Provider store={mockedStore}><div id="app"><Project/></div></Provider>); - - const container = getByTestId('proj-editor-container'); + const { getByTestId } = render( + <Provider store={mockedStore}> + <div id="app"> + <Project /> + </div> + </Provider>, + ); + + const container = getByTestId("proj-editor-container"); expect(container).toHaveStyle({ - 'min-width': '25%', - 'max-width': '100%', - 'width': '100%', - 'height': '50%', + "min-width": "25%", + "max-width": "100%", + width: "100%", + height: "50%", }); - expect(container.getElementsByClassName('resizable-with-handle__handle--bottom').length).toBe(1); - }) -}) + expect( + container.getElementsByClassName("resizable-with-handle__handle--bottom") + .length, + ).toBe(1); + }); +}); diff --git a/src/components/Editor/ProjectComponentLoader/ProjectComponentLoader.test.js b/src/components/Editor/ProjectComponentLoader/ProjectComponentLoader.test.js index 1f2b9dd2a..4e6fef29b 100644 --- a/src/components/Editor/ProjectComponentLoader/ProjectComponentLoader.test.js +++ b/src/components/Editor/ProjectComponentLoader/ProjectComponentLoader.test.js @@ -1,60 +1,73 @@ import React from "react"; -import { render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import ProjectComponentLoader from "./ProjectComponentLoader"; import { setProject } from "../EditorSlice"; import { defaultPythonProject } from "../../../utils/defaultProjects"; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => jest.fn() +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => jest.fn(), })); test("Renders loading message if loading is pending", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - loading: 'pending' + loading: "pending", }, - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - render(<Provider store={store}><ProjectComponentLoader match={{params: {}}}/></Provider>) - expect(screen.queryByText('project.loading')).toBeInTheDocument() -}) + render( + <Provider store={store}> + <ProjectComponentLoader match={{ params: {} }} /> + </Provider>, + ); + expect(screen.queryByText("project.loading")).toBeInTheDocument(); +}); test("Loads default project if loading fails", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - loading: 'failed' + loading: "failed", }, - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - render(<Provider store={store}><ProjectComponentLoader match={{params: {}}}/></Provider>) - const expectedActions = [setProject(defaultPythonProject)] - expect(store.getActions()).toEqual(expectedActions) -}) + render( + <Provider store={store}> + <ProjectComponentLoader match={{ params: {} }} /> + </Provider>, + ); + const expectedActions = [setProject(defaultPythonProject)]; + expect(store.getActions()).toEqual(expectedActions); +}); test("Does not render loading message if loading is success", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - components: [] + components: [], }, openFiles: [[]], focussedFileIndices: [0], - loading: 'success' + loading: "success", }, - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - render(<Provider store={store}><div id='app'></div><ProjectComponentLoader match={{params: {}}}/></Provider>) - expect(screen.queryByText('project.loading')).not.toBeInTheDocument() -}) + render( + <Provider store={store}> + <div id="app"></div> + <ProjectComponentLoader match={{ params: {} }} /> + </Provider>, + ); + expect(screen.queryByText("project.loading")).not.toBeInTheDocument(); +}); diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.js b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.js index 4eba59b5d..b980e90a5 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.js +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.js @@ -16,11 +16,11 @@ function HtmlRunner() { const projectCode = useSelector((state) => state.editor.project.components); const projectImages = useSelector((state) => state.editor.project.image_list); const codeRunTriggered = useSelector( - (state) => state.editor.codeRunTriggered + (state) => state.editor.codeRunTriggered, ); const firstPanelIndex = 0; const focussedFileIndex = useSelector( - (state) => state.editor.focussedFileIndices + (state) => state.editor.focussedFileIndices, )[firstPanelIndex]; const openFiles = useSelector((state) => state.editor.openFiles)[ firstPanelIndex @@ -38,7 +38,7 @@ function HtmlRunner() { const focussedComponent = (fileName = "index.html") => projectCode.filter( - (component) => `${component.name}.${component.extension}` === fileName + (component) => `${component.name}.${component.extension}` === fileName, )[0]; const previewable = (file) => file.endsWith(".html"); @@ -46,7 +46,7 @@ function HtmlRunner() { const [previewFile, setPreviewFile] = useState( previewable(openFiles[focussedFileIndex]) ? openFiles[focussedFileIndex] - : "index.html" + : "index.html", ); const showModal = () => { @@ -66,7 +66,7 @@ function HtmlRunner() { projectImages.forEach((image) => { updatedProjectFile.content = updatedProjectFile.content.replace( image.filename, - image.url + image.url, ); }); } @@ -131,7 +131,7 @@ function HtmlRunner() { // replace href's with blob urls hrefNodes.forEach((hrefNode) => { const projectFile = projectCode.filter( - (file) => `${file.name}.${file.extension}` === hrefNode.attrs.href + (file) => `${file.name}.${file.extension}` === hrefNode.attrs.href, ); // remove target blanks if (hrefNode.attrs?.target === "_blank") { @@ -144,7 +144,7 @@ function HtmlRunner() { if (parentTag(hrefNode, "head")) { const projectFileBlob = getBlobURL( cssProjectImgs(projectFile[0]).content, - `text/${projectFile[0].extension}` + `text/${projectFile[0].extension}`, ); hrefNode.setAttribute("href", projectFileBlob); } else { @@ -170,11 +170,11 @@ function HtmlRunner() { const srcNodes = indexPage.querySelectorAll("[src]"); srcNodes.forEach((srcNode) => { const projectImage = projectImages.filter( - (component) => component.filename === srcNode.attrs.src + (component) => component.filename === srcNode.attrs.src, ); srcNode.setAttribute( "src", - !!projectImage.length ? projectImage[0].url : "" + !!projectImage.length ? projectImage[0].url : "", ); }); diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js index a513730dc..2dbc72a4e 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js @@ -46,7 +46,7 @@ describe("When page first loaded", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); @@ -75,7 +75,7 @@ describe("When focussed on another HTML file", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); @@ -112,7 +112,7 @@ describe("When page first loaded in embedded viewer", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); @@ -122,7 +122,7 @@ describe("When page first loaded in embedded viewer", () => { test("Dispatches action to trigger code run", () => { expect(store.getActions()).toEqual( - expect.arrayContaining([triggerCodeRun()]) + expect.arrayContaining([triggerCodeRun()]), ); }); }); @@ -151,7 +151,7 @@ describe("When run run triggered", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); @@ -163,7 +163,7 @@ describe("When run run triggered", () => { test("Dispatches action to end code run", () => { expect(store.getActions()).toEqual( - expect.arrayContaining([codeRunHandled()]) + expect.arrayContaining([codeRunHandled()]), ); }); }); @@ -201,7 +201,7 @@ describe("When an external link is rendered", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); @@ -246,7 +246,7 @@ describe("When a new tab link is rendered", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); @@ -281,7 +281,7 @@ describe("When an allowed link is rendered", () => { <div id="app"> <HtmlRunner /> </div> - </Provider> + </Provider>, ); }); diff --git a/src/components/Editor/Runners/PythonRunner/OutputViewToggle.js b/src/components/Editor/Runners/PythonRunner/OutputViewToggle.js index 7a79b20ee..6b466fbc7 100644 --- a/src/components/Editor/Runners/PythonRunner/OutputViewToggle.js +++ b/src/components/Editor/Runners/PythonRunner/OutputViewToggle.js @@ -5,44 +5,54 @@ import Button from "../../../Button/Button"; import { setIsSplitView } from "../../EditorSlice"; import { useTranslation } from "react-i18next"; -import './OutputViewToggle.scss'; +import "./OutputViewToggle.scss"; const OutputViewToggle = () => { - - const isSplitView = useSelector((state) => state.editor.isSplitView) - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered) - const drawTriggered = useSelector((state) => state.editor.drawTriggered) - const dispatch = useDispatch() - const { t } = useTranslation() + const isSplitView = useSelector((state) => state.editor.isSplitView); + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); + const drawTriggered = useSelector((state) => state.editor.drawTriggered); + const dispatch = useDispatch(); + const { t } = useTranslation(); const switchToTabbedView = () => { - dispatch(setIsSplitView(false)) - } + dispatch(setIsSplitView(false)); + }; const switchToSplitView = () => { - dispatch(setIsSplitView(true)) - } + dispatch(setIsSplitView(true)); + }; return ( - <div className = {`output-view-toggle`} disabled = {codeRunTriggered || drawTriggered}> - <Button className = {`btn--small output-view-toggle__button output-view-toggle__button--tabbed${isSplitView ? "" : " output-view-toggle__button--active"}` } + <div + className={`output-view-toggle`} + disabled={codeRunTriggered || drawTriggered} + > + <Button + className={`btn--small output-view-toggle__button output-view-toggle__button--tabbed${ + isSplitView ? "" : " output-view-toggle__button--active" + }`} buttonOuter - disabled = {codeRunTriggered || drawTriggered} - label={t('outputViewToggle.buttonTabLabel')} - title={t('outputViewToggle.buttonTabTitle')} + disabled={codeRunTriggered || drawTriggered} + label={t("outputViewToggle.buttonTabLabel")} + title={t("outputViewToggle.buttonTabTitle")} ButtonIcon={TabbedViewIcon} onClickHandler={switchToTabbedView} /> - <Button className = {`btn--small output-view-toggle__button output-view-toggle__button--split${isSplitView ? " output-view-toggle__button--active" : ""}` } + <Button + className={`btn--small output-view-toggle__button output-view-toggle__button--split${ + isSplitView ? " output-view-toggle__button--active" : "" + }`} buttonOuter - disabled = {codeRunTriggered || drawTriggered} - label={t('outputViewToggle.buttonSplitLabel')} - title={t('outputViewToggle.buttonSplitTitle')} + disabled={codeRunTriggered || drawTriggered} + label={t("outputViewToggle.buttonSplitLabel")} + title={t("outputViewToggle.buttonSplitTitle")} ButtonIcon={SplitViewIcon} onClickHandler={switchToSplitView} /> </div> - ) -} + ); +}; -export default OutputViewToggle +export default OutputViewToggle; diff --git a/src/components/Editor/Runners/PythonRunner/OutputViewToggle.test.js b/src/components/Editor/Runners/PythonRunner/OutputViewToggle.test.js index 176e0f189..94c90811b 100644 --- a/src/components/Editor/Runners/PythonRunner/OutputViewToggle.test.js +++ b/src/components/Editor/Runners/PythonRunner/OutputViewToggle.test.js @@ -1,151 +1,203 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import OutputViewToggle from "./OutputViewToggle"; import { setIsSplitView } from "../../EditorSlice"; -describe('When in tabbed view', () => { +describe("When in tabbed view", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { isSplitView: false, - } - } + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - }) - - test('Tabbed view button is active', () => { - expect(screen.getAllByRole('button')[0]).toHaveClass('output-view-toggle__button--tabbed output-view-toggle__button--active') - }) - - test('Split view button is not active', () => { - expect(screen.getAllByRole('button')[1]).toHaveClass('output-view-toggle__button--split') - expect(screen.getAllByRole('button')[1]).not.toHaveClass('output-view-toggle__button--active') - }) -}) - -describe('When in split view', () => { + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + }); + + test("Tabbed view button is active", () => { + expect(screen.getAllByRole("button")[0]).toHaveClass( + "output-view-toggle__button--tabbed output-view-toggle__button--active", + ); + }); + + test("Split view button is not active", () => { + expect(screen.getAllByRole("button")[1]).toHaveClass( + "output-view-toggle__button--split", + ); + expect(screen.getAllByRole("button")[1]).not.toHaveClass( + "output-view-toggle__button--active", + ); + }); +}); + +describe("When in split view", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { isSplitView: true, - } - } - const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - }) - - test('Split view button is active', () => { - expect(screen.getAllByRole('button')[1]).toHaveClass('output-view-toggle__button--split output-view-toggle__button--active') - }) - - test('Tabbed view button is not active', () => { - expect(screen.getAllByRole('button')[0]).toHaveClass('output-view-toggle__button--tabbed') - expect(screen.getAllByRole('button')[0]).not.toHaveClass('output-view-toggle__button--active') - }) -}) - -test('Clicking tabbed view icon switches to tabbed view', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: {} - } - const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - fireEvent.click(screen.getAllByRole('button')[0]) - const expectedActions = [setIsSplitView(false)] - expect(store.getActions()).toEqual(expectedActions); -}) - -test('Clicking split view icon switches to tabbed view', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: {} - } + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - fireEvent.click(screen.getAllByRole('button')[1]) - const expectedActions = [setIsSplitView(true)] - expect(store.getActions()).toEqual(expectedActions); -}) - -describe('When in a code run is triggered', () => { + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + }); + + test("Split view button is active", () => { + expect(screen.getAllByRole("button")[1]).toHaveClass( + "output-view-toggle__button--split output-view-toggle__button--active", + ); + }); + + test("Tabbed view button is not active", () => { + expect(screen.getAllByRole("button")[0]).toHaveClass( + "output-view-toggle__button--tabbed", + ); + expect(screen.getAllByRole("button")[0]).not.toHaveClass( + "output-view-toggle__button--active", + ); + }); +}); + +test("Clicking tabbed view icon switches to tabbed view", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: {}, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + fireEvent.click(screen.getAllByRole("button")[0]); + const expectedActions = [setIsSplitView(false)]; + expect(store.getActions()).toEqual(expectedActions); +}); + +test("Clicking split view icon switches to tabbed view", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: {}, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + fireEvent.click(screen.getAllByRole("button")[1]); + const expectedActions = [setIsSplitView(true)]; + expect(store.getActions()).toEqual(expectedActions); +}); + +describe("When in a code run is triggered", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { codeRunTriggered: true, - } - } + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - }) - - test('Tabbed view button is disabled', () => { - expect(screen.getAllByRole('button')[0]).toHaveClass('output-view-toggle__button--tabbed') - expect(screen.getAllByRole('button')[0]).toBeDisabled() - }) - - test('Split view button is disabled', () => { - expect(screen.getAllByRole('button')[1]).toHaveClass('output-view-toggle__button--split') - expect(screen.getAllByRole('button')[1]).toBeDisabled() - }) -}) - -describe('When in a draw run is triggered', () => { + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + }); + + test("Tabbed view button is disabled", () => { + expect(screen.getAllByRole("button")[0]).toHaveClass( + "output-view-toggle__button--tabbed", + ); + expect(screen.getAllByRole("button")[0]).toBeDisabled(); + }); + + test("Split view button is disabled", () => { + expect(screen.getAllByRole("button")[1]).toHaveClass( + "output-view-toggle__button--split", + ); + expect(screen.getAllByRole("button")[1]).toBeDisabled(); + }); +}); + +describe("When in a draw run is triggered", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { drawTriggered: true, - } - } + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - }) - - test('Tabbed view button is disabled', () => { - expect(screen.getAllByRole('button')[0]).toHaveClass('output-view-toggle__button--tabbed') - expect(screen.getAllByRole('button')[0]).toBeDisabled() - }) - - test('Split view button is disabled', () => { - expect(screen.getAllByRole('button')[1]).toHaveClass('output-view-toggle__button--split') - expect(screen.getAllByRole('button')[1]).toBeDisabled() - }) -}) - -describe('When in neither a code run nor a draw run is triggered', () => { + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + }); + + test("Tabbed view button is disabled", () => { + expect(screen.getAllByRole("button")[0]).toHaveClass( + "output-view-toggle__button--tabbed", + ); + expect(screen.getAllByRole("button")[0]).toBeDisabled(); + }); + + test("Split view button is disabled", () => { + expect(screen.getAllByRole("button")[1]).toHaveClass( + "output-view-toggle__button--split", + ); + expect(screen.getAllByRole("button")[1]).toBeDisabled(); + }); +}); + +describe("When in neither a code run nor a draw run is triggered", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - codeRunTriggered:false, + codeRunTriggered: false, drawTriggered: false, - } - } + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><OutputViewToggle /></Provider>); - }) - - test('Tabbed view button is enabled', () => { - expect(screen.getAllByRole('button')[0]).toHaveClass('output-view-toggle__button--tabbed') - expect(screen.getAllByRole('button')[0]).not.toBeDisabled() - }) - - test('Split view button is enabled', () => { - expect(screen.getAllByRole('button')[1]).toHaveClass('output-view-toggle__button--split') - expect(screen.getAllByRole('button')[1]).not.toBeDisabled() - }) -}) + render( + <Provider store={store}> + <OutputViewToggle /> + </Provider>, + ); + }); + + test("Tabbed view button is enabled", () => { + expect(screen.getAllByRole("button")[0]).toHaveClass( + "output-view-toggle__button--tabbed", + ); + expect(screen.getAllByRole("button")[0]).not.toBeDisabled(); + }); + + test("Split view button is enabled", () => { + expect(screen.getAllByRole("button")[1]).toHaveClass( + "output-view-toggle__button--split", + ); + expect(screen.getAllByRole("button")[1]).not.toBeDisabled(); + }); +}); diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.js b/src/components/Editor/Runners/PythonRunner/PythonRunner.js index 500d46631..4e965fdb8 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.js +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.js @@ -1,74 +1,84 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import './PythonRunner.scss'; -import React, { useContext, useEffect, useRef, useState } from 'react'; +import "./PythonRunner.scss"; +import React, { useContext, useEffect, useRef, useState } from "react"; import * as Sentry from "@sentry/browser"; -import { useSelector, useDispatch } from 'react-redux' -import { useTranslation } from 'react-i18next'; -import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; -import Sk from "skulpt" -import { setError, codeRunHandled, stopDraw, setSenseHatEnabled, triggerDraw } from '../../EditorSlice' -import ErrorMessage from '../../ErrorMessage/ErrorMessage' - -import store from '../../../../app/store' -import VisualOutputPane from './VisualOutputPane'; -import OutputViewToggle from './OutputViewToggle'; -import { SettingsContext } from '../../../../settings'; +import { useSelector, useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; +import Sk from "skulpt"; +import { + setError, + codeRunHandled, + stopDraw, + setSenseHatEnabled, + triggerDraw, +} from "../../EditorSlice"; +import ErrorMessage from "../../ErrorMessage/ErrorMessage"; + +import store from "../../../../app/store"; +import VisualOutputPane from "./VisualOutputPane"; +import OutputViewToggle from "./OutputViewToggle"; +import { SettingsContext } from "../../../../settings"; const externalLibraries = { "./pygal/__init__.js": { path: `${process.env.PUBLIC_URL}/shims/pygal/pygal.js`, dependencies: [ - 'https://cdnjs.cloudflare.com/ajax/libs/highcharts/6.0.2/highcharts.js', - 'https://cdnjs.cloudflare.com/ajax/libs/highcharts/6.0.2/js/highcharts-more.js' + "https://cdnjs.cloudflare.com/ajax/libs/highcharts/6.0.2/highcharts.js", + "https://cdnjs.cloudflare.com/ajax/libs/highcharts/6.0.2/js/highcharts-more.js", ], }, "./py5/__init__.js": { path: `${process.env.PUBLIC_URL}/shims/processing/py5/py5-shim.js`, - dependencies: [ - 'https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.1/p5.js' - ] + dependencies: ["https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.1/p5.js"], }, "./py5_imported/__init__.js": { path: `${process.env.PUBLIC_URL}/shims/processing/py5_imported_mode/py5_imported.js`, }, "./py5_imported_mode.py": { - path: `${process.env.PUBLIC_URL}/shims/processing/py5_imported_mode/py5_imported_mode.py` + path: `${process.env.PUBLIC_URL}/shims/processing/py5_imported_mode/py5_imported_mode.py`, }, "./p5/__init__.js": { path: `${process.env.PUBLIC_URL}/shims/processing/p5/p5-shim.js`, - dependencies: [ - 'https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.1/p5.js' - ] + dependencies: ["https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.1/p5.js"], }, "./_internal_sense_hat/__init__.js": { - path: `${process.env.PUBLIC_URL}/shims/sense_hat/_internal_sense_hat.js` + path: `${process.env.PUBLIC_URL}/shims/sense_hat/_internal_sense_hat.js`, }, "./sense_hat.py": { - path: `${process.env.PUBLIC_URL}/shims/sense_hat/sense_hat_blob.py` - } + path: `${process.env.PUBLIC_URL}/shims/sense_hat/sense_hat_blob.py`, + }, }; const PythonRunner = () => { const projectCode = useSelector((state) => state.editor.project.components); const isSplitView = useSelector((state) => state.editor.isSplitView); const isEmbedded = useSelector((state) => state.editor.isEmbedded); - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered); + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); const codeRunStopped = useSelector((state) => state.editor.codeRunStopped); const drawTriggered = useSelector((state) => state.editor.drawTriggered); - const senseHatAlwaysEnabled = useSelector((state) => state.editor.senseHatAlwaysEnabled); + const senseHatAlwaysEnabled = useSelector( + (state) => state.editor.senseHatAlwaysEnabled, + ); const output = useRef(); const dispatch = useDispatch(); - const { t } = useTranslation() - const settings = useContext(SettingsContext) + const { t } = useTranslation(); + const settings = useContext(SettingsContext); - const queryParams = new URLSearchParams(window.location.search) - const [hasVisualOutput, setHasVisualOutput] = useState(queryParams.get('show_visual_tab') === 'true' || senseHatAlwaysEnabled) + const queryParams = new URLSearchParams(window.location.search); + const [hasVisualOutput, setHasVisualOutput] = useState( + queryParams.get("show_visual_tab") === "true" || senseHatAlwaysEnabled, + ); const getInput = () => { - const pageInput = document.getElementById("input") - const webComponentInput = document.querySelector('editor-wc') ? document.querySelector('editor-wc').shadowRoot.getElementById("input") : null; - return pageInput || webComponentInput - } + const pageInput = document.getElementById("input"); + const webComponentInput = document.querySelector("editor-wc") + ? document.querySelector("editor-wc").shadowRoot.getElementById("input") + : null; + return pageInput || webComponentInput; + }; useEffect(() => { if (codeRunTriggered) { @@ -78,63 +88,62 @@ const PythonRunner = () => { useEffect(() => { if (codeRunStopped && getInput()) { - const input = getInput() - input.removeAttribute("id") - input.removeAttribute("contentEditable") - dispatch(setError(t('output.errors.interrupted'))); - dispatch(codeRunHandled()) + const input = getInput(); + input.removeAttribute("id"); + input.removeAttribute("contentEditable"); + dispatch(setError(t("output.errors.interrupted"))); + dispatch(codeRunHandled()); } }, [codeRunStopped]); useEffect(() => { if (!codeRunTriggered && !drawTriggered) { if (getInput()) { - const input = getInput() - input.removeAttribute("id") - input.removeAttribute("contentEditable") + const input = getInput(); + input.removeAttribute("id"); + input.removeAttribute("contentEditable"); } } - }, - [drawTriggered, codeRunTriggered] - ) + }, [drawTriggered, codeRunTriggered]); - const visualLibraries =[ + const visualLibraries = [ "./pygal/__init__.js", "./py5/__init__.js", "./py5_imported/__init__.js", "./p5/__init__.js", "./_internal_sense_hat/__init__.js", - "src/builtin/turtle/__init__.js" - ] + "src/builtin/turtle/__init__.js", + ]; const outf = (text) => { if (text !== "") { const node = output.current; - const div = document.createElement("span") - div.classList.add('pythonrunner-console-output-line') + const div = document.createElement("span"); + div.classList.add("pythonrunner-console-output-line"); div.innerHTML = new Option(text).innerHTML; node.appendChild(div); node.scrollTop = node.scrollHeight; } - } + }; const builtinRead = (x) => { - - if (x==="./_internal_sense_hat/__init__.js") { - dispatch(setSenseHatEnabled(true)) + if (x === "./_internal_sense_hat/__init__.js") { + dispatch(setSenseHatEnabled(true)); } - if(x === "./p5/__init__.js" || x === "./py5/__init__.js") { - dispatch(triggerDraw()) + if (x === "./p5/__init__.js" || x === "./py5/__init__.js") { + dispatch(triggerDraw()); } - + // TODO: Handle pre-importing py5_imported when refactored py5 shim imported if (visualLibraries.includes(x)) { - setHasVisualOutput(true) + setHasVisualOutput(true); } - let localProjectFiles = projectCode.filter((component) => component.name !== 'main').map((component) => `./${component.name}.py`); + let localProjectFiles = projectCode + .filter((component) => component.name !== "main") + .map((component) => `./${component.name}.py`); if (localProjectFiles.includes(x)) { let filename = x.slice(2, -3); @@ -144,57 +153,78 @@ const PythonRunner = () => { } } - if (Sk.builtinFiles !== undefined && Sk.builtinFiles["files"][x] !== undefined) { + if ( + Sk.builtinFiles !== undefined && + Sk.builtinFiles["files"][x] !== undefined + ) { return Sk.builtinFiles["files"][x]; } if (externalLibraries[x]) { var externalLibraryInfo = externalLibraries[x]; - return externalLibraries[x].code || Sk.misceval.promiseToSuspension( - fetch(externalLibraryInfo.path).then((response) => response.text()).then((code) => { - if (!code) { - throw new Sk.builtin.ImportError("Failed to load remote module"); - } - externalLibraries[x].code = code - var promise; - - function mapUrlToPromise(path) { - // If the script is already in the DOM don't add it again. - const existingScriptElement = document.querySelector(`script[src="${path}"]`) - if(!existingScriptElement) { - return new Promise(function (resolve, _reject) { - let scriptElement = document.createElement("script"); - scriptElement.type = "text/javascript"; - scriptElement.src = path; - scriptElement.async = true - scriptElement.onload = function () { - resolve(true); + return ( + externalLibraries[x].code || + Sk.misceval.promiseToSuspension( + fetch(externalLibraryInfo.path) + .then((response) => response.text()) + .then((code) => { + if (!code) { + throw new Sk.builtin.ImportError( + "Failed to load remote module", + ); + } + externalLibraries[x].code = code; + var promise; + + function mapUrlToPromise(path) { + // If the script is already in the DOM don't add it again. + const existingScriptElement = document.querySelector( + `script[src="${path}"]`, + ); + if (!existingScriptElement) { + return new Promise(function (resolve, _reject) { + let scriptElement = document.createElement("script"); + scriptElement.type = "text/javascript"; + scriptElement.src = path; + scriptElement.async = true; + scriptElement.onload = function () { + resolve(true); + }; + + document.body.appendChild(scriptElement); + }); } - - document.body.appendChild(scriptElement); - }); - } - } - if (externalLibraryInfo.loadDepsSynchronously) { - promise = (externalLibraryInfo.dependencies || []).reduce((p, url) => { - return p.then(() => mapUrlToPromise(url)); - }, Promise.resolve()); // initial - } else { - promise = Promise.all((externalLibraryInfo.dependencies || []).map(mapUrlToPromise)); - } - - return promise.then(function () { - return code; - }).catch(function () { - throw new Sk.builtin.ImportError("Failed to load dependencies required"); - }); - }) + } + if (externalLibraryInfo.loadDepsSynchronously) { + promise = (externalLibraryInfo.dependencies || []).reduce( + (p, url) => { + return p.then(() => mapUrlToPromise(url)); + }, + Promise.resolve(), + ); // initial + } else { + promise = Promise.all( + (externalLibraryInfo.dependencies || []).map(mapUrlToPromise), + ); + } + + return promise + .then(function () { + return code; + }) + .catch(function () { + throw new Sk.builtin.ImportError( + "Failed to load dependencies required", + ); + }); + }), + ) ); } throw new Error("File not found: '" + x + "'"); - } + }; const inputSpan = () => { const span = document.createElement("span"); @@ -202,76 +232,81 @@ const PythonRunner = () => { span.setAttribute("spellCheck", "false"); span.setAttribute("class", "pythonrunner-input"); span.setAttribute("contentEditable", "true"); - return span - } + return span; + }; const inf = function () { if (Sk.sense_hat) { - Sk.sense_hat.mz_criteria.noInputEvents = false + Sk.sense_hat.mz_criteria.noInputEvents = false; } const outputPane = output.current; outputPane.appendChild(inputSpan()); - const input = getInput() + const input = getInput(); input.focus(); return new Promise(function (resolve, reject) { input.addEventListener("keydown", function removeInput(e) { if (e.key === "Enter") { - input.removeEventListener(e.type, removeInput) + input.removeEventListener(e.type, removeInput); // resolve the promise with the value of the input field const answer = input.innerText; input.removeAttribute("id"); input.removeAttribute("contentEditable"); - input.innerText=answer+'\n'; + input.innerText = answer + "\n"; document.addEventListener("keyup", function storeInput(e) { if (e.key === "Enter") { document.removeEventListener(e.type, storeInput); resolve(answer); } - }) + }); } - }) - }) - } + }); + }); + }; const handleError = (err) => { - let errorMessage - if (err.message === t('output.errors.interrupted')) { - errorMessage = err.message + let errorMessage; + if (err.message === t("output.errors.interrupted")) { + errorMessage = err.message; } else { - const errorDetails = (err.tp$str && err.tp$str().v).replace(/\[(.*?)\]/, "").replace(/\.$/, '') - const errorType = err.tp$name || err.constructor.name - const lineNumber = err.traceback[0].lineno - const fileName = err.traceback[0].filename.replace(/^\.\//, '') - - Sentry.captureMessage(`${errorType}: ${errorDetails}`) + const errorDetails = (err.tp$str && err.tp$str().v) + .replace(/\[(.*?)\]/, "") + .replace(/\.$/, ""); + const errorType = err.tp$name || err.constructor.name; + const lineNumber = err.traceback[0].lineno; + const fileName = err.traceback[0].filename.replace(/^\.\//, ""); + + Sentry.captureMessage(`${errorType}: ${errorDetails}`); errorMessage = `${errorType}: ${errorDetails} on line ${lineNumber} of ${fileName}`; } dispatch(setError(errorMessage)); dispatch(stopDraw()); if (getInput()) { - const input = getInput() - input.removeAttribute("id") - input.removeAttribute("contentEditable") + const input = getInput(); + input.removeAttribute("id"); + input.removeAttribute("contentEditable"); } - } + }; const runCode = () => { // clear previous output dispatch(setError("")); - output.current.innerHTML = ''; - dispatch(setSenseHatEnabled(false)) + output.current.innerHTML = ""; + dispatch(setSenseHatEnabled(false)); var prog = projectCode[0].content; - if (prog.includes(`# ${t('input.comment.py5')}`)) { - prog = prog.replace(`# ${t('input.comment.py5')}`,'from py5_imported_mode import *') + if (prog.includes(`# ${t("input.comment.py5")}`)) { + prog = prog.replace( + `# ${t("input.comment.py5")}`, + "from py5_imported_mode import *", + ); - if (! prog.match(/(run_sketch)/)) { - prog = prog.concat('\nrun_sketch()') + if (!prog.match(/(run_sketch)/)) { + prog = prog.concat("\nrun_sketch()"); } } @@ -281,25 +316,25 @@ const PythonRunner = () => { read: builtinRead, debugging: true, inputTakesPrompt: true, - uncaughtException: handleError + uncaughtException: handleError, }); - var myPromise = Sk.misceval.asyncToPromise(() => - Sk.importMainWithBody("main", false, prog, true), { - "*": () => { - if (store.getState().editor.codeRunStopped) { - throw new Error(t('output.errors.interrupted')); - } + var myPromise = Sk.misceval + .asyncToPromise(() => Sk.importMainWithBody("main", false, prog, true), { + "*": () => { + if (store.getState().editor.codeRunStopped) { + throw new Error(t("output.errors.interrupted")); } }, - ).catch(err => { - handleError(err) - }).finally(()=>{ - dispatch(codeRunHandled()); - }); - myPromise.then(function (_mod) { - }) - } + }) + .catch((err) => { + handleError(err); + }) + .finally(() => { + dispatch(codeRunHandled()); + }); + myPromise.then(function (_mod) {}); + }; function shiftFocusToInput(e) { if (document.getSelection().toString().length > 0) { @@ -308,82 +343,100 @@ const PythonRunner = () => { const inputBox = getInput(); if (inputBox && e.target !== inputBox) { - const input = getInput() + const input = getInput(); const selection = window.getSelection(); selection.removeAllRanges(); - if (input.innerText && input.innerText.length > 0){ + if (input.innerText && input.innerText.length > 0) { const range = document.createRange(); range.setStart(input, 1); range.collapse(true); selection.addRange(range); } - input.focus() + input.focus(); } } return ( <div className={`pythonrunner-container`}> - { isSplitView ? + {isSplitView ? ( <> - {hasVisualOutput ? <div className='output-panel output-panel--visual'> + {hasVisualOutput ? ( + <div className="output-panel output-panel--visual"> + <Tabs forceRenderTabPanel={true}> + <div className="react-tabs__tab-container"> + <TabList> + <Tab key={0}> + <span className="react-tabs__tab-inner"> + {t("output.visualOutput")} + </span> + </Tab> + </TabList> + {!isEmbedded ? <OutputViewToggle /> : null} + </div> + <TabPanel key={0}> + <VisualOutputPane /> + </TabPanel> + </Tabs> + </div> + ) : null} + <div className="output-panel output-panel--text"> <Tabs forceRenderTabPanel={true}> - <div className='react-tabs__tab-container'> + <div className="react-tabs__tab-container"> <TabList> <Tab key={0}> - <span className='react-tabs__tab-inner'>{t('output.visualOutput')}</span> + <span className="react-tabs__tab-inner"> + {t("output.textOutput")} + </span> </Tab> </TabList> - {!isEmbedded ? <OutputViewToggle/> : null } - </div> - <TabPanel key={0} > - <VisualOutputPane/> - </TabPanel> - </Tabs> - </div> : null} - <div className='output-panel output-panel--text'> - <Tabs forceRenderTabPanel={true}> - <div className='react-tabs__tab-container'> - <TabList> - <Tab key={0}> - <span className='react-tabs__tab-inner'>{t('output.textOutput')}</span> - </Tab> - </TabList> - { hasVisualOutput || isEmbedded ? null : <OutputViewToggle /> } + {hasVisualOutput || isEmbedded ? null : <OutputViewToggle />} </div> <ErrorMessage /> <TabPanel key={0}> - <pre className={`pythonrunner-console pythonrunner-console--${settings.fontSize}`} onClick={shiftFocusToInput} ref={output}></pre> + <pre + className={`pythonrunner-console pythonrunner-console--${settings.fontSize}`} + onClick={shiftFocusToInput} + ref={output} + ></pre> </TabPanel> </Tabs> </div> - </> - : - <Tabs forceRenderTabPanel={true} defaultIndex={hasVisualOutput ? 0 : 1}> - <div className='react-tabs__tab-container'> - <TabList> - {hasVisualOutput ? - <Tab key={0}> - <span className='react-tabs__tab-inner'>{t('output.visualOutput')}</span> - </Tab> : null - } - <Tab key={1}> - <span className='react-tabs__tab-inner'>{t('output.textOutput')}</span> - </Tab> - </TabList> - {!isEmbedded ? <OutputViewToggle/> : null } - </div> - <ErrorMessage /> - {hasVisualOutput ? - <TabPanel key={0} > - <VisualOutputPane/> - </TabPanel> : null - } - <TabPanel key={1}> - <pre className={`pythonrunner-console pythonrunner-console--${settings.fontSize}`} onClick={shiftFocusToInput} ref={output}></pre> - </TabPanel> - </Tabs> - } + </> + ) : ( + <Tabs forceRenderTabPanel={true} defaultIndex={hasVisualOutput ? 0 : 1}> + <div className="react-tabs__tab-container"> + <TabList> + {hasVisualOutput ? ( + <Tab key={0}> + <span className="react-tabs__tab-inner"> + {t("output.visualOutput")} + </span> + </Tab> + ) : null} + <Tab key={1}> + <span className="react-tabs__tab-inner"> + {t("output.textOutput")} + </span> + </Tab> + </TabList> + {!isEmbedded ? <OutputViewToggle /> : null} + </div> + <ErrorMessage /> + {hasVisualOutput ? ( + <TabPanel key={0}> + <VisualOutputPane /> + </TabPanel> + ) : null} + <TabPanel key={1}> + <pre + className={`pythonrunner-console pythonrunner-console--${settings.fontSize}`} + onClick={shiftFocusToInput} + ref={output} + ></pre> + </TabPanel> + </Tabs> + )} </div> ); }; diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js b/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js index 00cb5ef50..bb013ca5c 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import PythonRunner from "./PythonRunner"; import { codeRunHandled, setError, triggerDraw } from "../../EditorSlice"; @@ -12,585 +12,679 @@ describe("Testing basic input span functionality", () => { let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "input()" - } + content: "input()", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - } - } + }, + }; store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>); + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); input = document.getElementById("input"); - }) + }); test("Input function in code makes editable input box appear", () => { expect(input).toHaveAttribute("contentEditable", "true"); - }) + }); test("Input box has focus when it appears", () => { expect(input).toHaveFocus(); - }) + }); test("Clicking output pane transfers focus to input", () => { - const outputPane = document.getElementsByClassName("pythonrunner-console")[0] + const outputPane = document.getElementsByClassName( + "pythonrunner-console", + )[0]; fireEvent.click(outputPane); expect(input).toHaveFocus(); - }) + }); test("Pressing enter stops the input box being editable", () => { - const inputText = 'hello world'; + const inputText = "hello world"; input.innerText = inputText; - fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter", charCode: 13 }); expect(input).not.toHaveAttribute("contentEditable", "true"); - expect(input.innerText).toBe(inputText + '\n'); - }) -}) + expect(input.innerText).toBe(inputText + "\n"); + }); +}); test("Input box not there when input function not called", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "print('Hello')" - } + content: "print('Hello')", + }, ], - image_list: [] + image_list: [], }, - codeRunTriggered: true - } - } + codeRunTriggered: true, + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>); - expect(document.getElementById("input")).toBeNull() - -}) + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + expect(document.getElementById("input")).toBeNull(); +}); describe("Testing stopping the code run with input", () => { let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "input()" - } + content: "input()", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - codeRunStopped: true - } - } + codeRunStopped: true, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>); - }) + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + }); test("Disables input span", () => { expect(document.getElementById("input")).toBeNull(); - }) + }); test("Sets interruption error", () => { - expect(store.getActions()).toEqual(expect.arrayContaining([setError('output.errors.interrupted')])) - }) + expect(store.getActions()).toEqual( + expect.arrayContaining([setError("output.errors.interrupted")]), + ); + }); test("Handles code run", () => { - expect(store.getActions()).toEqual(expect.arrayContaining([codeRunHandled()])) - }) -}) + expect(store.getActions()).toEqual( + expect.arrayContaining([codeRunHandled()]), + ); + }); +}); -describe('When in split view, no visual libraries used and code run', () => { +describe("When in split view, no visual libraries used and code run", () => { let store; let queryByText; - + beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "print('hello world')" - } + content: "print('hello world')", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: true - } - } + isSplitView: true, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is not shown', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).not.toBeInTheDocument() - }) -}) - -describe('When in split view, py5 imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is not shown", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).not.toBeInTheDocument(); + }); +}); + +describe("When in split view, py5 imported and code run", () => { let store; let queryByText; - + beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import py5" - } + content: "import py5", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: true - } - } + isSplitView: true, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is shown', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) - - test('Draw is triggered', () => { - expect(store.getActions()).toEqual(expect.arrayContaining([triggerDraw()])) - }) -}) - -describe('When in split view, py5_imported imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is shown", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); + + test("Draw is triggered", () => { + expect(store.getActions()).toEqual(expect.arrayContaining([triggerDraw()])); + }); +}); + +describe("When in split view, py5_imported imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import py5_imported" - } + content: "import py5_imported", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: true - } - } + isSplitView: true, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is shown', async () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) - -describe('When in split view, pygal imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is shown", async () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in split view, pygal imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import pygal" - } + content: "import pygal", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: true - } - } + isSplitView: true, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is shown', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) - -describe('When in split view, turtle imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is shown", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in split view, turtle imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import turtle" - } + content: "import turtle", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: true - } - } + isSplitView: true, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is shown', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) - -describe('When in split view, sense_hat imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is shown", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in split view, sense_hat imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import _internal_sense_hat" - } + content: "import _internal_sense_hat", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: true - } - } + isSplitView: true, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is shown', async () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() }) -}) - -describe('When in tabbed view, no visual libraries used and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is shown", async () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in tabbed view, no visual libraries used and code run", () => { let store; let queryByText; - + beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "print('hello world')" - } + content: "print('hello world')", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: false - } - } + isSplitView: false, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is not shown', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).not.toBeInTheDocument() - }) -}) - -describe('When in tabbed view, py5 imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is not shown", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).not.toBeInTheDocument(); + }); +}); + +describe("When in tabbed view, py5 imported and code run", () => { let store; let queryByText; - + beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import py5" - } + content: "import py5", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: false - } - } + isSplitView: false, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is not hidden', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) - - test('Draw is triggered', () => { - expect(store.getActions()).toEqual(expect.arrayContaining([triggerDraw()])) - }) -}) - -describe('When in tabbed view, py5_imported imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is not hidden", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); + + test("Draw is triggered", () => { + expect(store.getActions()).toEqual(expect.arrayContaining([triggerDraw()])); + }); +}); + +describe("When in tabbed view, py5_imported imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import py5_imported" - } + content: "import py5_imported", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: false - } - } + isSplitView: false, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is not hidden', async () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) - -describe('When in tabbed view, pygal imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is not hidden", async () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in tabbed view, pygal imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import pygal" - } + content: "import pygal", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: false - } - } + isSplitView: false, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is not hidden', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) - -describe('When in tabbed view, turtle imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is not hidden", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in tabbed view, turtle imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import turtle" - } + content: "import turtle", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: false - } - } + isSplitView: false, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) - - test('Visual tab is not hidden', () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) - -describe('When in tabbed view, sense_hat imported and code run', () => { + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); + + test("Visual tab is not hidden", () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); + +describe("When in tabbed view, sense_hat imported and code run", () => { let store; let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import _internal_sense_hat" - } + content: "import _internal_sense_hat", + }, ], - image_list: [] + image_list: [], }, codeRunTriggered: true, - isSplitView: false - } - } + isSplitView: false, + }, + }; store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><PythonRunner /></Provider>)); - }) + ({ queryByText } = render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + )); + }); - test('Visual tab is not hidden', async () => { - const visualTab = queryByText('output.visualOutput') - expect(visualTab).toBeInTheDocument() - }) -}) + test("Visual tab is not hidden", async () => { + const visualTab = queryByText("output.visualOutput"); + expect(visualTab).toBeInTheDocument(); + }); +}); test("When embedded in split view with visual output does not render output view toggle", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: { - components: [ - { - content: "import p5" - } - ], - }, - codeRUnTriggered: true, - isSplitView: true, - isEmbedded: true - } - } - const store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>) - expect(screen.queryByRole('button')).not.toBeInTheDocument() -}) + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: { + components: [ + { + content: "import p5", + }, + ], + }, + codeRUnTriggered: true, + isSplitView: true, + isEmbedded: true, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); +}); test("When embedded in split view with no visual output does not render output view toggle", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: {}, - senseHatAlwaysEnabled: false, - isSplitView: true, - isEmbedded: true - } - } - const store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>) - expect(screen.queryByRole('button')).not.toBeInTheDocument() -}) + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: {}, + senseHatAlwaysEnabled: false, + isSplitView: true, + isEmbedded: true, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); +}); test("When embedded in tabbed view does not render output view toggle", () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: {}, - isSplitView: false, - isEmbedded: true - } - } - const store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>) - expect(screen.queryByRole('button')).not.toBeInTheDocument() -}) - -test('Tabbed view has text and visual tabs with same parent element', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: {}, - senseHatAlwaysEnabled: true, - isSplitView: false - } - } - const store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>) - expect(screen.getByText('output.visualOutput').parentElement.parentElement).toEqual(screen.getByText('output.textOutput').parentElement.parentElement) -}) - -test('Split view has text and visual tabs with different parent elements', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: {}, - senseHatAlwaysEnabled: true, - isSplitView: true - } - } - const store = mockStore(initialState); - render(<Provider store={store}><PythonRunner /></Provider>) - expect(screen.getByText('output.visualOutput').parentElement).not.toEqual(screen.getByText('output.textOutput').parentElement) -}) + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: {}, + isSplitView: false, + isEmbedded: true, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); +}); + +test("Tabbed view has text and visual tabs with same parent element", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: {}, + senseHatAlwaysEnabled: true, + isSplitView: false, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + expect( + screen.getByText("output.visualOutput").parentElement.parentElement, + ).toEqual(screen.getByText("output.textOutput").parentElement.parentElement); +}); + +test("Split view has text and visual tabs with different parent elements", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: {}, + senseHatAlwaysEnabled: true, + isSplitView: true, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <PythonRunner /> + </Provider>, + ); + expect(screen.getByText("output.visualOutput").parentElement).not.toEqual( + screen.getByText("output.textOutput").parentElement, + ); +}); + +describe("When font size is set", () => { + let runnerContainer; -describe('When font size is set', () => { - let runnerContainer - beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - project: {} - } - } + project: {}, + }, + }; const store = mockStore(initialState); runnerContainer = render( <Provider store={store}> - <SettingsContext.Provider value={{ theme: 'dark', fontSize: 'myFontSize' }}> + <SettingsContext.Provider + value={{ theme: "dark", fontSize: "myFontSize" }} + > <PythonRunner /> </SettingsContext.Provider> - </Provider> - ) - }) - - test('Font size class is set correctly', () => { - const runnerConsole = runnerContainer.container.querySelector('.pythonrunner-console') - expect(runnerConsole).toHaveClass("pythonrunner-console--myFontSize") - }) -}) + </Provider>, + ); + }); + + test("Font size class is set correctly", () => { + const runnerConsole = runnerContainer.container.querySelector( + ".pythonrunner-console", + ); + expect(runnerConsole).toHaveClass("pythonrunner-console--myFontSize"); + }); +}); diff --git a/src/components/Editor/Runners/PythonRunner/VisualOutputPane.js b/src/components/Editor/Runners/PythonRunner/VisualOutputPane.js index 923e4593a..8eb56f6ee 100644 --- a/src/components/Editor/Runners/PythonRunner/VisualOutputPane.js +++ b/src/components/Editor/Runners/PythonRunner/VisualOutputPane.js @@ -2,15 +2,18 @@ import React, { useEffect } from "react"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; -import Sk from "skulpt" +import Sk from "skulpt"; import AstroPiModel from "../../../AstroPiModel/AstroPiModel"; import { codeRunHandled, setError } from "../../EditorSlice"; const VisualOutputPane = () => { - - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered) - const drawTriggered = useSelector((state) => state.editor.drawTriggered) - const senseHatAlwaysEnabled = useSelector((state) => state.editor.senseHatAlwaysEnabled); + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); + const drawTriggered = useSelector((state) => state.editor.drawTriggered); + const senseHatAlwaysEnabled = useSelector( + (state) => state.editor.senseHatAlwaysEnabled, + ); const senseHatEnabled = useSelector((state) => state.editor.senseHatEnabled); const projectImages = useSelector((state) => state.editor.project.image_list); const error = useSelector((state) => state.editor.error); @@ -19,56 +22,67 @@ const VisualOutputPane = () => { const pygalOutput = useRef(); const p5Output = useRef(); const dispatch = useDispatch(); - const { t } = useTranslation() + const { t } = useTranslation(); useEffect(() => { if (codeRunTriggered) { - outputCanvas.current.innerHTML = ''; - pygalOutput.current.innerHTML = ''; - p5Output.current.innerHTML = ''; + outputCanvas.current.innerHTML = ""; + pygalOutput.current.innerHTML = ""; + p5Output.current.innerHTML = ""; - Sk.py5 = {} + Sk.py5 = {}; Sk.py5.sketch = "p5Sketch"; Sk.py5.assets = projectImages; - Sk.p5 = {} + Sk.p5 = {}; Sk.p5.sketch = "p5Sketch"; Sk.p5.assets = projectImages; - + (Sk.pygal || (Sk.pygal = {})).outputCanvas = pygalOutput.current; - - (Sk.TurtleGraphics || (Sk.TurtleGraphics = {})).target = 'outputCanvas'; - Sk.TurtleGraphics.assets = Object.assign({}, ...projectImages.map((image) => ({[`${image.name}.${image.extension}`]: image.url}))) - + + (Sk.TurtleGraphics || (Sk.TurtleGraphics = {})).target = "outputCanvas"; + Sk.TurtleGraphics.assets = Object.assign( + {}, + ...projectImages.map((image) => ({ + [`${image.name}.${image.extension}`]: image.url, + })), + ); } - }, [codeRunTriggered, projectImages]) + }, [codeRunTriggered, projectImages]); useEffect(() => { - if (!drawTriggered && p5Output.current && p5Output.current.innerHTML !== '') { + if ( + !drawTriggered && + p5Output.current && + p5Output.current.innerHTML !== "" + ) { if (Sk.py5.stop) { - Sk.py5.stop() + Sk.py5.stop(); } else { - Sk.p5.stop() + Sk.p5.stop(); } - - if(error === ''){ - dispatch(setError(t('output.errors.interrupted'))) + + if (error === "") { + dispatch(setError(t("output.errors.interrupted"))); } - dispatch(codeRunHandled()) + dispatch(codeRunHandled()); } - - }, [drawTriggered, dispatch, t, error]) + }, [drawTriggered, dispatch, t, error]); return ( - <div className='visual-output'> - <div id='p5Sketch' ref={p5Output} /> - <div id='pygalOutput' ref={pygalOutput} /> + <div className="visual-output"> + <div id="p5Sketch" ref={p5Output} /> + <div id="pygalOutput" ref={pygalOutput} /> <div className="pythonrunner-canvas-container"> - <div id='outputCanvas' ref={outputCanvas} className="pythonrunner-graphic" /> + <div + id="outputCanvas" + ref={outputCanvas} + className="pythonrunner-graphic" + /> </div> - {senseHatEnabled || senseHatAlwaysEnabled ?<AstroPiModel/>:null} + {senseHatEnabled || senseHatAlwaysEnabled ? <AstroPiModel /> : null} </div> - ) -} + ); +}; -export default VisualOutputPane +export default VisualOutputPane; diff --git a/src/components/Editor/Runners/PythonRunner/VisualOutputPane.test.js b/src/components/Editor/Runners/PythonRunner/VisualOutputPane.test.js index 0b06504b1..a3d38c9f4 100644 --- a/src/components/Editor/Runners/PythonRunner/VisualOutputPane.test.js +++ b/src/components/Editor/Runners/PythonRunner/VisualOutputPane.test.js @@ -1,100 +1,111 @@ import React from "react"; -import { render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import VisualOutputPane from "./VisualOutputPane"; -import Sk from 'skulpt'; +import Sk from "skulpt"; describe("When Sense Hat library used", () => { let canvas; let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "import _internal_sense_hat" - } + content: "import _internal_sense_hat", + }, ], - image_list: [] + image_list: [], }, - codeRunTriggered: true - } - } + codeRunTriggered: true, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><VisualOutputPane /></Provider>); - canvas = document.getElementsByClassName("sense-hat")[0] - }) + render( + <Provider store={store}> + <VisualOutputPane /> + </Provider>, + ); + canvas = document.getElementsByClassName("sense-hat")[0]; + }); test("Astro Pi component appears", () => { - expect(canvas).not.toBeNull() - }) -}) + expect(canvas).not.toBeNull(); + }); +}); describe("When Sense Hat library not used", () => { let canvas; let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - content: "print('Hello world')" - } + content: "print('Hello world')", + }, ], - image_list: [] + image_list: [], }, - codeRunTriggered: true - } - } + codeRunTriggered: true, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><VisualOutputPane /></Provider>); - canvas = document.getElementsByClassName("sense-hat")[0] - }) + render( + <Provider store={store}> + <VisualOutputPane /> + </Provider>, + ); + canvas = document.getElementsByClassName("sense-hat")[0]; + }); test("Astro Pi component does not appear", () => { - expect(canvas).not.toBeDefined() - }) -}) - -describe("When code run is triggered",() => { + expect(canvas).not.toBeDefined(); + }); +}); +describe("When code run is triggered", () => { let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [], - image_list: [] + image_list: [], }, - codeRunTriggered: true - } - } + codeRunTriggered: true, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><VisualOutputPane /></Provider>); - }) + render( + <Provider store={store}> + <VisualOutputPane /> + </Provider>, + ); + }); - test('Sets up p5 canvas', () => { - expect(Sk.py5.sketch).not.toBeNull() - }) + test("Sets up p5 canvas", () => { + expect(Sk.py5.sketch).not.toBeNull(); + }); - test('Sets up pygal canvas', () => { - expect(Sk.pygal.outputCanvas).not.toBeNull() - }) + test("Sets up pygal canvas", () => { + expect(Sk.pygal.outputCanvas).not.toBeNull(); + }); - test('Sets up turtle canvas', () => { - expect(Sk.TurtleGraphics.target).not.toBeNull() - }) -}) + test("Sets up turtle canvas", () => { + expect(Sk.TurtleGraphics.target).not.toBeNull(); + }); +}); diff --git a/src/components/Editor/Runners/RunnerFactory.js b/src/components/Editor/Runners/RunnerFactory.js index 0d66ce9f5..12f6aee4a 100644 --- a/src/components/Editor/Runners/RunnerFactory.js +++ b/src/components/Editor/Runners/RunnerFactory.js @@ -1,21 +1,18 @@ -import React from 'react'; -import PythonRunner from './PythonRunner/PythonRunner'; -import HtmlRunner from './HtmlRunner/HtmlRunner'; +import React from "react"; +import PythonRunner from "./PythonRunner/PythonRunner"; +import HtmlRunner from "./HtmlRunner/HtmlRunner"; const RunnerFactory = ({ projectType }) => { const Runner = () => { - if (projectType === 'html') { + if (projectType === "html") { return HtmlRunner; } return PythonRunner; - } + }; const Selected = Runner(); - return ( - <Selected /> - ) -} + return <Selected />; +}; export default RunnerFactory; - diff --git a/src/components/Editor/editorDarkTheme.js b/src/components/Editor/editorDarkTheme.js index bf4907d53..74b0e1c94 100644 --- a/src/components/Editor/editorDarkTheme.js +++ b/src/components/Editor/editorDarkTheme.js @@ -1,40 +1,43 @@ -import { EditorView } from '@codemirror/view'; -import '../../font-weight.scss'; -import '../../font-size.scss'; -import '../../line-height.scss'; +import { EditorView } from "@codemirror/view"; +import "../../font-weight.scss"; +import "../../font-size.scss"; +import "../../line-height.scss"; -export const editorDarkTheme = EditorView.theme({ - ".cm-gutters": { - "background-color": "#2A2B32", - "color": "white", - "border": "none" +export const editorDarkTheme = EditorView.theme( + { + ".cm-gutters": { + "background-color": "#2A2B32", + color: "white", + border: "none", + }, + ".cm-activeLine": { + "background-color": "inherit", + }, + ".cm-activeLineGutter": { + "background-color": "inherit", + color: "inherit", + }, + "&.cm-focused .cm-selectionBackground, ::selection": { + background: "#144866", + }, + "&.cm-focused .cm-cursor": { + borderLeftColor: "white", + }, + ".cm-line .cm-indentation-marker": { + background: "none", + "border-left": "solid grey", + "&.active": { + background: "none", + "border-left": "solid lightgrey", + }, + }, + ".ͼb": { color: "#FF00A4" }, + ".ͼc": { color: "#1498D0" }, + ".ͼd": { color: "#1498D0" }, + ".ͼe": { color: "#6CE68D" }, + ".ͼg": { color: "#1498D0" }, + ".ͼj": { color: "#1498D0" }, + ".ͼm": { color: "#C1C1C1" }, }, - ".cm-activeLine": { - "background-color": "inherit", - }, - ".cm-activeLineGutter": { - "background-color": "inherit", - "color": "inherit" - }, - "&.cm-focused .cm-selectionBackground, ::selection": { - "background": "#144866" - }, - "&.cm-focused .cm-cursor": { - borderLeftColor: "white" - }, - ".cm-line .cm-indentation-marker": { - 'background': 'none', - 'border-left': 'solid grey', - "&.active": { - 'background': 'none', - 'border-left': 'solid lightgrey', - } - }, - ".ͼb": {color: "#FF00A4"}, - ".ͼc": {color: "#1498D0"}, - ".ͼd": {color: "#1498D0"}, - ".ͼe": {color: "#6CE68D"}, - ".ͼg": {color: "#1498D0"}, - ".ͼj": {color: "#1498D0"}, - ".ͼm": {color: "#C1C1C1"}, -}, {dark: true}) + { dark: true }, +); diff --git a/src/components/Editor/editorLightTheme.js b/src/components/Editor/editorLightTheme.js index 25d6b3c42..bdaf6b39e 100644 --- a/src/components/Editor/editorLightTheme.js +++ b/src/components/Editor/editorLightTheme.js @@ -1,24 +1,27 @@ -import { EditorView } from '@codemirror/view'; +import { EditorView } from "@codemirror/view"; -export const editorLightTheme = EditorView.theme({ - ".cm-activeLine": { - "background-color": "inherit", +export const editorLightTheme = EditorView.theme( + { + ".cm-activeLine": { + "background-color": "inherit", + }, + ".cm-activeLineGutter": { + "background-color": "inherit", + color: "inherit", + }, + ".cm-gutters": { + border: "none", + color: "black", + "background-color": "white", + }, + ".cm-line .cm-indentation-marker": { + background: "none", + "border-left": "solid lightgrey", + "&.active": { + background: "none", + "border-left": "solid grey", + }, + }, }, - ".cm-activeLineGutter": { - "background-color": "inherit", - "color": "inherit" - }, - ".cm-gutters": { - "border": "none", - "color": "black", - "background-color": "white" - }, - ".cm-line .cm-indentation-marker": { - 'background': 'none', - 'border-left': 'solid lightgrey', - "&.active": { - 'background': 'none', - 'border-left': 'solid grey', - } - }, -}, {dark: false}) + { dark: false }, +); diff --git a/src/components/EmbeddedViewer/EmbeddedControls/EmbeddedControls.js b/src/components/EmbeddedViewer/EmbeddedControls/EmbeddedControls.js index 574b5d1b4..78ba5105a 100644 --- a/src/components/EmbeddedViewer/EmbeddedControls/EmbeddedControls.js +++ b/src/components/EmbeddedViewer/EmbeddedControls/EmbeddedControls.js @@ -1,12 +1,12 @@ -import './EmbeddedControls.css'; -import RunnerControls from '../../RunButton/RunnerControls'; +import "./EmbeddedControls.css"; +import RunnerControls from "../../RunButton/RunnerControls"; const EmbeddedControls = () => { - return ( - <div className = "embedded-controls"> - <RunnerControls/> - </div> - ) -} + return ( + <div className="embedded-controls"> + <RunnerControls /> + </div> + ); +}; export default EmbeddedControls; diff --git a/src/components/EmbeddedViewer/EmbeddedViewer.test.js b/src/components/EmbeddedViewer/EmbeddedViewer.test.js index 3747b57cb..c1a3f5ca2 100644 --- a/src/components/EmbeddedViewer/EmbeddedViewer.test.js +++ b/src/components/EmbeddedViewer/EmbeddedViewer.test.js @@ -1,33 +1,32 @@ -import React from 'react'; -import EmbeddedViewer from './EmbeddedViewer'; +import React from "react"; +import EmbeddedViewer from "./EmbeddedViewer"; -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import { render } from '@testing-library/react'; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import { render } from "@testing-library/react"; -let store +let store; beforeEach(() => { const middlewares = []; const mockStore = configureStore(middlewares); const initialState = { - editor: { - loading: 'success', - project: { - components: [] - } - } + editor: { + loading: "success", + project: { + components: [], + }, + }, }; store = mockStore(initialState); +}); -}) - -test('Renders without crashing', () => { +test("Renders without crashing", () => { const { asFragment } = render( <Provider store={store}> <EmbeddedViewer /> - </Provider> + </Provider>, ); expect(asFragment()).toMatchSnapshot(); }); diff --git a/src/components/ExternalFiles/ExternalFiles.js b/src/components/ExternalFiles/ExternalFiles.js index fc3fd36d5..6c6683988 100644 --- a/src/components/ExternalFiles/ExternalFiles.js +++ b/src/components/ExternalFiles/ExternalFiles.js @@ -1,20 +1,23 @@ -import React from 'react'; -import { useSelector } from 'react-redux' +import React from "react"; +import { useSelector } from "react-redux"; const ExternalFiles = () => { const project = useSelector((state) => state.editor.project); return ( - <div id='file-content' hidden> - {project.components.map((file, i) => { - if(['csv', 'txt'].includes(file.extension)) { - return <div id={`${file.name}.${file.extension}`} key={i}>{file.content}</div> - } - return null - })} - - </div> - ) + <div id="file-content" hidden> + {project.components.map((file, i) => { + if (["csv", "txt"].includes(file.extension)) { + return ( + <div id={`${file.name}.${file.extension}`} key={i}> + {file.content} + </div> + ); + } + return null; + })} + </div> + ); }; -export default ExternalFiles +export default ExternalFiles; diff --git a/src/components/ExternalFiles/ExternalFiles.test.js b/src/components/ExternalFiles/ExternalFiles.test.js index 3c182a1b7..a05e55d91 100644 --- a/src/components/ExternalFiles/ExternalFiles.test.js +++ b/src/components/ExternalFiles/ExternalFiles.test.js @@ -1,28 +1,32 @@ import React from "react"; import { render } from "@testing-library/react"; -import { Provider } from 'react-redux'; +import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import ExternalFiles from "./ExternalFiles"; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); test("External files component contains text from file with filename id", () => { - const initialState = { - editor: { - project: { - components: [ - { - name: "hello", - extension: "txt", - content: "hello world!" - } - ] - } - } - } - const store = mockStore(initialState) - const {getByText} = render(<Provider store={store}><ExternalFiles /></Provider>); - const fileContent = getByText("hello world!") - expect(fileContent).toHaveAttribute('id', 'hello.txt') -}) + const initialState = { + editor: { + project: { + components: [ + { + name: "hello", + extension: "txt", + content: "hello world!", + }, + ], + }, + }, + }; + const store = mockStore(initialState); + const { getByText } = render( + <Provider store={store}> + <ExternalFiles /> + </Provider>, + ); + const fileContent = getByText("hello world!"); + expect(fileContent).toHaveAttribute("id", "hello.txt"); +}); diff --git a/src/components/Footer/Footer.js b/src/components/Footer/Footer.js index 3d29b0ba6..6a844badb 100644 --- a/src/components/Footer/Footer.js +++ b/src/components/Footer/Footer.js @@ -1,20 +1,42 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import './Footer.scss'; +import "./Footer.scss"; const Footer = () => { - const { t } = useTranslation() + const { t } = useTranslation(); return ( - <footer className='editor-footer'> - <span className='editor-footer__name'>{t('footer.charityNameAndNumber')}</span> - <div className='editor-footer__links'> - <a className='editor-footer__links-link' href='https://www.raspberrypi.org/privacy/child-friendly'>{t('footer.privacy')}</a> - <a className='editor-footer__links-link' href='https://www.raspberrypi.org/cookies'>{t('footer.cookies')}</a> - <a className='editor-footer__links-link' href='https://www.raspberrypi.org/accessibility'>{t('footer.accessibility')}</a> - <a className='editor-footer__links-link' href='https://www.raspberrypi.org/safeguarding'>{t('footer.safeguarding')}</a> + <footer className="editor-footer"> + <span className="editor-footer__name"> + {t("footer.charityNameAndNumber")} + </span> + <div className="editor-footer__links"> + <a + className="editor-footer__links-link" + href="https://www.raspberrypi.org/privacy/child-friendly" + > + {t("footer.privacy")} + </a> + <a + className="editor-footer__links-link" + href="https://www.raspberrypi.org/cookies" + > + {t("footer.cookies")} + </a> + <a + className="editor-footer__links-link" + href="https://www.raspberrypi.org/accessibility" + > + {t("footer.accessibility")} + </a> + <a + className="editor-footer__links-link" + href="https://www.raspberrypi.org/safeguarding" + > + {t("footer.safeguarding")} + </a> </div> </footer> - ) -} + ); +}; export default Footer; diff --git a/src/components/Footer/Footer.test.js b/src/components/Footer/Footer.test.js index a2ed4dabd..3802b8bee 100644 --- a/src/components/Footer/Footer.test.js +++ b/src/components/Footer/Footer.test.js @@ -3,27 +3,39 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import Footer from "./Footer"; -test('Footer renders', () => { - render(<Footer/>) - expect(screen.queryByText('footer.charityNameAndNumber')).toBeInTheDocument() -}) +test("Footer renders", () => { + render(<Footer />); + expect(screen.queryByText("footer.charityNameAndNumber")).toBeInTheDocument(); +}); -test('Links to privacy policy', () => { - render(<Footer/>) - expect(screen.queryByText('footer.privacy')).toHaveAttribute('href', 'https://www.raspberrypi.org/privacy/child-friendly') -}) +test("Links to privacy policy", () => { + render(<Footer />); + expect(screen.queryByText("footer.privacy")).toHaveAttribute( + "href", + "https://www.raspberrypi.org/privacy/child-friendly", + ); +}); -test('Links to cookie policy', () => { - render(<Footer/>) - expect(screen.queryByText('footer.cookies')).toHaveAttribute('href', 'https://www.raspberrypi.org/cookies') -}) +test("Links to cookie policy", () => { + render(<Footer />); + expect(screen.queryByText("footer.cookies")).toHaveAttribute( + "href", + "https://www.raspberrypi.org/cookies", + ); +}); -test('Links to accessibility policy', () => { - render(<Footer/>) - expect(screen.queryByText('footer.accessibility')).toHaveAttribute('href', 'https://www.raspberrypi.org/accessibility') -}) +test("Links to accessibility policy", () => { + render(<Footer />); + expect(screen.queryByText("footer.accessibility")).toHaveAttribute( + "href", + "https://www.raspberrypi.org/accessibility", + ); +}); -test('Links to safeguarding policy', () => { - render(<Footer/>) - expect(screen.queryByText('footer.safeguarding')).toHaveAttribute('href', 'https://www.raspberrypi.org/safeguarding') -}) +test("Links to safeguarding policy", () => { + render(<Footer />); + expect(screen.queryByText("footer.safeguarding")).toHaveAttribute( + "href", + "https://www.raspberrypi.org/safeguarding", + ); +}); diff --git a/src/components/GlobalNav/GlobalNav.js b/src/components/GlobalNav/GlobalNav.js index c5226cc7a..3abedc108 100644 --- a/src/components/GlobalNav/GlobalNav.js +++ b/src/components/GlobalNav/GlobalNav.js @@ -3,33 +3,42 @@ import { useSelector } from "react-redux"; import { ChevronDown } from "../../Icons"; import LoginMenu from "../Login/LoginMenu"; import Dropdown from "../Menus/Dropdown/Dropdown"; -import './GlobalNav.scss'; -import rpf_logo from '../../assets/raspberrypi_logo.svg' -import user_logo from '../../assets/unauthenticated_user.svg' +import "./GlobalNav.scss"; +import rpf_logo from "../../assets/raspberrypi_logo.svg"; +import user_logo from "../../assets/unauthenticated_user.svg"; import { useTranslation } from "react-i18next"; const GlobalNav = () => { - const { t } = useTranslation() + const { t } = useTranslation(); - const user = useSelector((state) => state.auth.user) + const user = useSelector((state) => state.auth.user); return ( <div className="editor-global-nav-wrapper"> - <div className='editor-global-nav'> - <a className='editor-global-nav__home' href='https://www.raspberrypi.org/'> - <img src={rpf_logo} alt={t('globalNav.raspberryPiLogoAltText')} /> + <div className="editor-global-nav"> + <a + className="editor-global-nav__home" + href="https://www.raspberrypi.org/" + > + <img src={rpf_logo} alt={t("globalNav.raspberryPiLogoAltText")} /> <span>Raspberry Pi Foundation</span> </a> - <div className='editor-global-nav__account'> + <div className="editor-global-nav__account"> <Dropdown buttonImage={user ? user.profile.picture : user_logo} - buttonImageAltText={user ? t('globalNav.accountMenuProfileAltText', { name: user.profile.name }) : t('globalNav.accountMenuDefaultAltText')} + buttonImageAltText={ + user + ? t("globalNav.accountMenuProfileAltText", { + name: user.profile.name, + }) + : t("globalNav.accountMenuDefaultAltText") + } ButtonIcon={ChevronDown} MenuContent={LoginMenu} /> </div> </div> </div> - ) -} + ); +}; export default GlobalNav; diff --git a/src/components/GlobalNav/GlobalNav.test.js b/src/components/GlobalNav/GlobalNav.test.js index a87292aff..0501de51f 100644 --- a/src/components/GlobalNav/GlobalNav.test.js +++ b/src/components/GlobalNav/GlobalNav.test.js @@ -1,44 +1,60 @@ import React from "react"; -import { render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import GlobalNav from "./GlobalNav"; -test('Global nav renders', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) +test("Global nav renders", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - render(<Provider store={store}><GlobalNav/></Provider>); - expect(screen.queryByText("Raspberry Pi Foundation")).toBeInTheDocument() -}) + render( + <Provider store={store}> + <GlobalNav /> + </Provider>, + ); + expect(screen.queryByText("Raspberry Pi Foundation")).toBeInTheDocument(); +}); -test('When not logged in renders generic profile image', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) +test("When not logged in renders generic profile image", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { - auth: {} - } + auth: {}, + }; const store = mockStore(initialState); - render(<Provider store={store}><GlobalNav/></Provider>); - expect(screen.queryByAltText('globalNav.accountMenuDefaultAltText')).toBeInTheDocument() -}) + render( + <Provider store={store}> + <GlobalNav /> + </Provider>, + ); + expect( + screen.queryByAltText("globalNav.accountMenuDefaultAltText"), + ).toBeInTheDocument(); +}); -test('When logged in renders user\'s profile image', () => { - const middlewares = [] - const mockStore = configureStore(middlewares) +test("When logged in renders user's profile image", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { auth: { user: { profile: { - picture: 'image_url' - } - } - } - } + picture: "image_url", + }, + }, + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><GlobalNav/></Provider>); - expect(screen.queryByAltText('globalNav.accountMenuProfileAltText')).toHaveAttribute('src', 'image_url') -}) + render( + <Provider store={store}> + <GlobalNav /> + </Provider>, + ); + expect( + screen.queryByAltText("globalNav.accountMenuProfileAltText"), + ).toHaveAttribute("src", "image_url"); +}); diff --git a/src/components/Header/Autosave.js b/src/components/Header/Autosave.js index 9550a75b7..7e7444341 100644 --- a/src/components/Header/Autosave.js +++ b/src/components/Header/Autosave.js @@ -1,44 +1,53 @@ -import { intlFormatDistance } from 'date-fns' +import { intlFormatDistance } from "date-fns"; -import { useState, useEffect} from 'react' -import { useSelector } from 'react-redux' -import { useTranslation } from 'react-i18next'; - -import { CloudUploadIcon, CloudTickIcon } from '../../Icons'; +import { useState, useEffect } from "react"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { CloudUploadIcon, CloudTickIcon } from "../../Icons"; const Autosave = () => { - const { t } = useTranslation() - const lastSavedTime = useSelector((state) => state.editor.lastSavedTime) - const saving = useSelector((state) => state.editor.saving) + const { t } = useTranslation(); + const lastSavedTime = useSelector((state) => state.editor.lastSavedTime); + const saving = useSelector((state) => state.editor.saving); const [time, setTime] = useState(Date.now()); - const isPending = saving === 'pending' + const isPending = saving === "pending"; useEffect(() => { - setTime(Date.now()) + setTime(Date.now()); const statusTick = setInterval(() => { - setTime(Date.now()) + setTime(Date.now()); }, 10000); - return () => clearInterval(statusTick) + return () => clearInterval(statusTick); }, [lastSavedTime]); return ( - <div className='autosave'> - { isPending ? + <div className="autosave"> + {isPending ? ( <> - <div className='autosave__icon'><CloudUploadIcon /></div> - <div className='autosave__status'>{t('header.autoSaving')}…</div> - </> : + <div className="autosave__icon"> + <CloudUploadIcon /> + </div> + <div className="autosave__status"> + {t("header.autoSaving")}… + </div> + </> + ) : ( <> - <div className='autosave__icon'><CloudTickIcon /></div> - <div className='autosave__status'>{t('header.autoSaved')} {intlFormatDistance(lastSavedTime, time, { style: 'narrow' })}</div> + <div className="autosave__icon"> + <CloudTickIcon /> + </div> + <div className="autosave__status"> + {t("header.autoSaved")}{" "} + {intlFormatDistance(lastSavedTime, time, { style: "narrow" })} + </div> </> - } + )} </div> - ) -} + ); +}; -export default Autosave \ No newline at end of file +export default Autosave; diff --git a/src/components/Header/DownloadButton.js b/src/components/Header/DownloadButton.js index 40a076153..32bbd3d76 100644 --- a/src/components/Header/DownloadButton.js +++ b/src/components/Header/DownloadButton.js @@ -10,41 +10,51 @@ import Button from "../Button/Button"; import { closeLoginToSaveModal } from "../Editor/EditorSlice"; const DownloadButton = (props) => { - const { buttonText, className, Icon } = props - const { t } = useTranslation() - const project = useSelector((state) => state.editor.project) - const loginToSaveModalShowing = useSelector((state) => state.editor.loginToSaveModalShowing) - const dispatch = useDispatch() + const { buttonText, className, Icon } = props; + const { t } = useTranslation(); + const project = useSelector((state) => state.editor.project); + const loginToSaveModalShowing = useSelector( + (state) => state.editor.loginToSaveModalShowing, + ); + const dispatch = useDispatch(); const urlToPromise = (url) => { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve, reject) { JSZipUtils.getBinaryContent(url, function (err, data) { - if(err) { + if (err) { reject(err); } else { resolve(data); } }); }); - } + }; const onClickDownload = async () => { if (loginToSaveModalShowing) { - dispatch(closeLoginToSaveModal()) + dispatch(closeLoginToSaveModal()); } - const zip = new JSZip() + const zip = new JSZip(); project.components.forEach((file) => { - zip.file(`${file.name}.${file.extension}`, file.content) - }) + zip.file(`${file.name}.${file.extension}`, file.content); + }); project.image_list.forEach((image) => { - zip.file(image.filename, urlToPromise(image.url), {binary: true} ) - }) + zip.file(image.filename, urlToPromise(image.url), { binary: true }); + }); - const content = await zip.generateAsync({type: 'blob'}) - FileSaver.saveAs(content, `${toSnakeCase(project.name || t('header.downloadFileNameDefault', {project_type: project.project_type}))}`) - } + const content = await zip.generateAsync({ type: "blob" }); + FileSaver.saveAs( + content, + `${toSnakeCase( + project.name || + t("header.downloadFileNameDefault", { + project_type: project.project_type, + }), + )}`, + ); + }; return ( <Button @@ -53,7 +63,7 @@ const DownloadButton = (props) => { buttonText={buttonText} ButtonIcon={Icon} /> - ) -} + ); +}; -export default DownloadButton +export default DownloadButton; diff --git a/src/components/Header/DownloadButton.test.js b/src/components/Header/DownloadButton.test.js index 46b4d669c..cb85cab10 100644 --- a/src/components/Header/DownloadButton.test.js +++ b/src/components/Header/DownloadButton.test.js @@ -1,123 +1,150 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import DownloadButton from "./DownloadButton"; -import FileSaver from 'file-saver'; -import JSZip from 'jszip' -import JSZipUtils from 'jszip-utils' +import FileSaver from "file-saver"; +import JSZip from "jszip"; +import JSZipUtils from "jszip-utils"; import { closeLoginToSaveModal } from "../Editor/EditorSlice"; -jest.mock("file-saver") -jest.mock("jszip") +jest.mock("file-saver"); +jest.mock("jszip"); jest.mock("jszip-utils", () => ({ - getBinaryContent: jest.fn() -})) - -describe('Downloading project with name set', () => { + getBinaryContent: jest.fn(), +})); +describe("Downloading project with name set", () => { let downloadButton; beforeEach(() => { JSZip.mockClear(); - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - name: 'My epic project', + name: "My epic project", identifier: "hello-world-project", components: [ { - name: 'main', - extension: 'py', - content: 'print(\'hello world\')' - - } + name: "main", + extension: "py", + content: "print('hello world')", + }, ], image_list: [ { - url: 'a.com/b' - } - ] + url: "a.com/b", + }, + ], }, }, - } + }; const store = mockStore(initialState); - render(<Provider store={store}><DownloadButton buttonText='Download'/></Provider>) - downloadButton = screen.queryByText('Download').parentElement - }) - - test('Download button renders', ()=> { - expect(downloadButton).toBeInTheDocument() - }) - - test('Clicking download zips project file content', async () => { - fireEvent.click(downloadButton) + render( + <Provider store={store}> + <DownloadButton buttonText="Download" /> + </Provider>, + ); + downloadButton = screen.queryByText("Download").parentElement; + }); + + test("Download button renders", () => { + expect(downloadButton).toBeInTheDocument(); + }); + + test("Clicking download zips project file content", async () => { + fireEvent.click(downloadButton); const JSZipInstance = JSZip.mock.instances[0]; const mockFile = JSZipInstance.file; - await waitFor( () => expect(mockFile).toHaveBeenCalledWith('main.py', 'print(\'hello world\')')) - }) - - test('Clicking download triggers request for image', async () => { - fireEvent.click(downloadButton) - await waitFor(() => expect(JSZipUtils.getBinaryContent).toHaveBeenCalledWith('a.com/b', expect.anything())) - }) - - test('Clicking download button creates download with correct name', async () => { - fireEvent.click(downloadButton) - await waitFor( () => expect(FileSaver.saveAs).toHaveBeenCalledWith(undefined, 'my_epic_project')) - }) -}) + await waitFor(() => + expect(mockFile).toHaveBeenCalledWith("main.py", "print('hello world')"), + ); + }); + + test("Clicking download triggers request for image", async () => { + fireEvent.click(downloadButton); + await waitFor(() => + expect(JSZipUtils.getBinaryContent).toHaveBeenCalledWith( + "a.com/b", + expect.anything(), + ), + ); + }); + + test("Clicking download button creates download with correct name", async () => { + fireEvent.click(downloadButton); + await waitFor(() => + expect(FileSaver.saveAs).toHaveBeenCalledWith( + undefined, + "my_epic_project", + ), + ); + }); +}); -describe('Downloading project with no name set', () => { +describe("Downloading project with no name set", () => { let downloadButton; beforeEach(() => { JSZip.mockClear(); - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { - name: 'main', - extension: 'py', - content: '' - } + name: "main", + extension: "py", + content: "", + }, ], - image_list: [] + image_list: [], }, }, - } + }; const store = mockStore(initialState); - render(<Provider store={store}><DownloadButton buttonText='Download'/></Provider>) - downloadButton = screen.queryByText('Download').parentElement - }) + render( + <Provider store={store}> + <DownloadButton buttonText="Download" /> + </Provider>, + ); + downloadButton = screen.queryByText("Download").parentElement; + }); - test('Clicking download button creates download with default name', async () => { - fireEvent.click(downloadButton) - await waitFor( () => expect(FileSaver.saveAs).toHaveBeenCalledWith(undefined, 'header_download_file_name_default')) - }) -}) + test("Clicking download button creates download with default name", async () => { + fireEvent.click(downloadButton); + await waitFor(() => + expect(FileSaver.saveAs).toHaveBeenCalledWith( + undefined, + "header_download_file_name_default", + ), + ); + }); +}); -test('If login to save modal open, closes it when download clicked', () => { +test("If login to save modal open, closes it when download clicked", () => { JSZip.mockClear(); - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [], - image_list: [] + image_list: [], }, - loginToSaveModalShowing: true + loginToSaveModalShowing: true, }, - } + }; const store = mockStore(initialState); - render(<Provider store={store}><DownloadButton buttonText='Download'/></Provider>) - const downloadButton = screen.queryByText('Download').parentElement - fireEvent.click(downloadButton) - expect(store.getActions()).toEqual([closeLoginToSaveModal()]) -}) + render( + <Provider store={store}> + <DownloadButton buttonText="Download" /> + </Provider>, + ); + const downloadButton = screen.queryByText("Download").parentElement; + fireEvent.click(downloadButton); + expect(store.getActions()).toEqual([closeLoginToSaveModal()]); +}); diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index 1cb2a0e03..076f59956 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -1,68 +1,104 @@ -import './Header.scss' -import { useSelector, useDispatch } from 'react-redux' -import { useTranslation } from 'react-i18next'; -import Autosave from './Autosave'; -import Button from '../Button/Button'; -import { DownloadIcon, HomeIcon, SettingsIcon } from '../../Icons'; -import { syncProject, showLoginToSaveModal } from '../Editor/EditorSlice'; -import Dropdown from '../Menus/Dropdown/Dropdown'; -import SettingsMenu from '../Menus/SettingsMenu/SettingsMenu'; -import ProjectName from './ProjectName'; -import htmlLogo from '../../assets/html_icon.svg' -import pythonLogo from '../../assets/python_icon.svg' -import DownloadButton from './DownloadButton'; -import { isOwner } from '../../utils/projectHelpers' -import { Link } from 'react-router-dom'; +import "./Header.scss"; +import { useSelector, useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import Autosave from "./Autosave"; +import Button from "../Button/Button"; +import { DownloadIcon, HomeIcon, SettingsIcon } from "../../Icons"; +import { syncProject, showLoginToSaveModal } from "../Editor/EditorSlice"; +import Dropdown from "../Menus/Dropdown/Dropdown"; +import SettingsMenu from "../Menus/SettingsMenu/SettingsMenu"; +import ProjectName from "./ProjectName"; +import htmlLogo from "../../assets/html_icon.svg"; +import pythonLogo from "../../assets/python_icon.svg"; +import DownloadButton from "./DownloadButton"; +import { isOwner } from "../../utils/projectHelpers"; +import { Link } from "react-router-dom"; const Header = () => { - const dispatch = useDispatch() - const { t, i18n } = useTranslation() - - const user = useSelector((state) => state.auth.user) - const project = useSelector((state) => state.editor.project) - const loading = useSelector((state) => state.editor.loading) - const saving = useSelector((state) => state.editor.saving) - const lastSavedTime = useSelector((state) => state.editor.lastSavedTime) - const locale = i18n.language + const dispatch = useDispatch(); + const { t, i18n } = useTranslation(); + + const user = useSelector((state) => state.auth.user); + const project = useSelector((state) => state.editor.project); + const loading = useSelector((state) => state.editor.loading); + const saving = useSelector((state) => state.editor.saving); + const lastSavedTime = useSelector((state) => state.editor.lastSavedTime); + const locale = i18n.language; const onClickSave = async () => { - window.plausible('Save button') + window.plausible("Save button"); if (isOwner(user, project)) { - dispatch(syncProject('save')({project, accessToken: user.access_token, autosave: false})) + dispatch( + syncProject("save")({ + project, + accessToken: user.access_token, + autosave: false, + }), + ); } else if (user && project.identifier) { - dispatch(syncProject('remix')({project, accessToken: user.access_token})) + dispatch( + syncProject("remix")({ project, accessToken: user.access_token }), + ); } else { - dispatch(showLoginToSaveModal()) + dispatch(showLoginToSaveModal()); } - } + }; - return loading === 'success' && ( - <div className='editor-header-wrapper'> - <header className='editor-header'> - <img className='editor-logo' src={project.project_type === 'python' ? pythonLogo : htmlLogo } alt={t('header.editorLogoAltText')}/> - { user !== null ? ( - <Link to={`${locale}/projects`} className='project-gallery-link' reloadDocument> - {<><HomeIcon /> - <span className='editor-header__text'>{t('header.projects')}</span></>}</Link> - ) : null } - { loading === 'success' ? <ProjectName /> : null } - <div className='editor-header__right'> - { lastSavedTime && user ? <Autosave saving={saving} lastSavedTime={lastSavedTime} /> : null } - { loading === 'success' ? - <DownloadButton buttonText={t('header.download')} className='btn--tertiary' Icon={DownloadIcon}/> - : null } - <Dropdown - ButtonIcon={SettingsIcon} - buttonText={t('header.settings')} - MenuContent={SettingsMenu} /> - {loading === 'success' ? - <Button className='btn--primary btn--save' onClickHandler = {onClickSave} buttonText = {t('header.save')} /> - : null } - </div> - </header> - </div> - ) + return ( + loading === "success" && ( + <div className="editor-header-wrapper"> + <header className="editor-header"> + <img + className="editor-logo" + src={project.project_type === "python" ? pythonLogo : htmlLogo} + alt={t("header.editorLogoAltText")} + /> + {user !== null ? ( + <Link + to={`${locale}/projects`} + className="project-gallery-link" + reloadDocument + > + { + <> + <HomeIcon /> + <span className="editor-header__text"> + {t("header.projects")} + </span> + </> + } + </Link> + ) : null} + {loading === "success" ? <ProjectName /> : null} + <div className="editor-header__right"> + {lastSavedTime && user ? ( + <Autosave saving={saving} lastSavedTime={lastSavedTime} /> + ) : null} + {loading === "success" ? ( + <DownloadButton + buttonText={t("header.download")} + className="btn--tertiary" + Icon={DownloadIcon} + /> + ) : null} + <Dropdown + ButtonIcon={SettingsIcon} + buttonText={t("header.settings")} + MenuContent={SettingsMenu} + /> + {loading === "success" ? ( + <Button + className="btn--primary btn--save" + onClickHandler={onClickSave} + buttonText={t("header.save")} + /> + ) : null} + </div> + </header> + </div> + ) + ); }; export default Header; diff --git a/src/components/Header/Header.test.js b/src/components/Header/Header.test.js index fcd847ee5..80fdc0207 100644 --- a/src/components/Header/Header.test.js +++ b/src/components/Header/Header.test.js @@ -1,227 +1,264 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import Header from "./Header"; import { syncProject, showLoginToSaveModal } from "../Editor/EditorSlice"; import { MemoryRouter } from "react-router-dom"; -jest.mock('axios'); +jest.mock("axios"); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => jest.fn() +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => jest.fn(), })); -jest.mock('../Editor/EditorSlice', () => ({ - ...jest.requireActual('../Editor/EditorSlice'), - syncProject: jest.fn((_) => jest.fn()) -})) +jest.mock("../Editor/EditorSlice", () => ({ + ...jest.requireActual("../Editor/EditorSlice"), + syncProject: jest.fn((_) => jest.fn()), +})); const project = { - name: 'Hello world', + name: "Hello world", identifier: "hello-world-project", components: [], image_list: [], - user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" -} + user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", +}; const user = { access_token: "39a09671-be55-4847-baf5-8919a0c24a25", profile: { - user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" - } -} - + user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", + }, +}; describe("When logged in and user owns project", () => { let store; let saveButton; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - loading: 'success', + loading: "success", }, auth: { - user: user - } - } + user: user, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><Header/></MemoryRouter></Provider>); - saveButton = screen.queryByText('header.save') - }) + render( + <Provider store={store}> + <MemoryRouter> + <Header /> + </MemoryRouter> + </Provider>, + ); + saveButton = screen.queryByText("header.save"); + }); test("Renders project gallery link", () => { - expect(screen.queryByText('header.projects')).not.toBeNull(); - }) + expect(screen.queryByText("header.projects")).not.toBeNull(); + }); - test('Project name is shown', () => { - expect(screen.queryByText(project.name)).toBeInTheDocument() - }) + test("Project name is shown", () => { + expect(screen.queryByText(project.name)).toBeInTheDocument(); + }); - test('Download button shown', () => { - expect(screen.queryByText('header.download')).toBeInTheDocument() - }) + test("Download button shown", () => { + expect(screen.queryByText("header.download")).toBeInTheDocument(); + }); test("Clicking save dispatches saveProject with correct parameters", async () => { - const saveAction = {type: 'SAVE_PROJECT' } - const saveProject = jest.fn(() => saveAction) - syncProject.mockImplementationOnce(jest.fn((_) => (saveProject))) - fireEvent.click(saveButton) - await waitFor(() => expect(saveProject).toHaveBeenCalledWith({ - project, - accessToken: user.access_token, - autosave: false - })) - expect(store.getActions()[0]).toEqual(saveAction) - }) -}) + const saveAction = { type: "SAVE_PROJECT" }; + const saveProject = jest.fn(() => saveAction); + syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + fireEvent.click(saveButton); + await waitFor(() => + expect(saveProject).toHaveBeenCalledWith({ + project, + accessToken: user.access_token, + autosave: false, + }), + ); + expect(store.getActions()[0]).toEqual(saveAction); + }); +}); describe("When logged in and no project identifier", () => { let store; - const project_without_id = { ...project, identifier: null } + const project_without_id = { ...project, identifier: null }; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project_without_id, - loading: 'success', + loading: "success", }, auth: { - user: user - } - } + user: user, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><Header/></MemoryRouter></Provider>); - }) - - test('Download button shown', () => { - expect(screen.queryByText('header.download')).toBeInTheDocument() - }) - - test('Project name is shown', () => { - expect(screen.queryByText(project.name)).toBeInTheDocument() - }) - - test("Clicking save dispatches saveProject with correct parameters", async () => { - const saveAction = {type: 'SAVE_PROJECT' } - const saveProject = jest.fn(() => saveAction) - syncProject.mockImplementationOnce(jest.fn((_) => (saveProject))) - const saveButton = screen.getByText('header.save') - fireEvent.click(saveButton) - await waitFor(() => expect(saveProject).toHaveBeenCalledWith({ - project: project_without_id, - accessToken: user.access_token, - autosave: false - })) - expect(store.getActions()[0]).toEqual(saveAction) - }) -}) + render( + <Provider store={store}> + <MemoryRouter> + <Header /> + </MemoryRouter> + </Provider>, + ); + }); + + test("Download button shown", () => { + expect(screen.queryByText("header.download")).toBeInTheDocument(); + }); + + test("Project name is shown", () => { + expect(screen.queryByText(project.name)).toBeInTheDocument(); + }); + + test("Clicking save dispatches saveProject with correct parameters", async () => { + const saveAction = { type: "SAVE_PROJECT" }; + const saveProject = jest.fn(() => saveAction); + syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + const saveButton = screen.getByText("header.save"); + fireEvent.click(saveButton); + await waitFor(() => + expect(saveProject).toHaveBeenCalledWith({ + project: project_without_id, + accessToken: user.access_token, + autosave: false, + }), + ); + expect(store.getActions()[0]).toEqual(saveAction); + }); +}); describe("When logged in and user does not own project", () => { - const another_project = { ...project, user_id: '5254370e-26d2-4c8a-9526-8dbafea43aa9'} + const another_project = { + ...project, + user_id: "5254370e-26d2-4c8a-9526-8dbafea43aa9", + }; let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: another_project, - loading: 'success', + loading: "success", }, auth: { - user: user - } - } + user: user, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><Header/></MemoryRouter></Provider>); - }) + render( + <Provider store={store}> + <MemoryRouter> + <Header /> + </MemoryRouter> + </Provider>, + ); + }); test("Clicking save dispatches remixProject with correct parameters", async () => { - const remixAction = {type: 'REMIX_PROJECT' } - const remixProject = jest.fn(() => (remixAction)) - syncProject.mockImplementationOnce(jest.fn((_) => remixProject)) - const saveButton = screen.getByText('header.save') - fireEvent.click(saveButton) - await waitFor(() => expect(remixProject).toHaveBeenCalledWith({ - project: another_project, - accessToken: user.access_token - })) - expect(store.getActions()[0]).toEqual(remixAction) - }) -}) + const remixAction = { type: "REMIX_PROJECT" }; + const remixProject = jest.fn(() => remixAction); + syncProject.mockImplementationOnce(jest.fn((_) => remixProject)); + const saveButton = screen.getByText("header.save"); + fireEvent.click(saveButton); + await waitFor(() => + expect(remixProject).toHaveBeenCalledWith({ + project: another_project, + accessToken: user.access_token, + }), + ); + expect(store.getActions()[0]).toEqual(remixAction); + }); +}); describe("When not logged in", () => { - let store + let store; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { - editor: { - project: project, - loading: 'success', - }, - auth: { - user: null - } - } + editor: { + project: project, + loading: "success", + }, + auth: { + user: null, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><Header/></MemoryRouter></Provider>); - }) + render( + <Provider store={store}> + <MemoryRouter> + <Header /> + </MemoryRouter> + </Provider>, + ); + }); test("No project gallery link", () => { - expect(screen.queryByText('header.projects')).toBeNull(); - }) + expect(screen.queryByText("header.projects")).toBeNull(); + }); - test('Download button shown', () => { - expect(screen.queryByText('header.download')).toBeInTheDocument() - }) + test("Download button shown", () => { + expect(screen.queryByText("header.download")).toBeInTheDocument(); + }); - test('Project name is shown', () => { - expect(screen.queryByText(project.name)).toBeInTheDocument() - }) + test("Project name is shown", () => { + expect(screen.queryByText(project.name)).toBeInTheDocument(); + }); - test('Clicking save opens login to save modal', () => { - const saveButton = screen.getByText('header.save') - fireEvent.click(saveButton) - expect(store.getActions()).toEqual([showLoginToSaveModal()]) - }) -}) - -describe('When no project loaded', () => { + test("Clicking save opens login to save modal", () => { + const saveButton = screen.getByText("header.save"); + fireEvent.click(saveButton); + expect(store.getActions()).toEqual([showLoginToSaveModal()]); + }); +}); +describe("When no project loaded", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { - editor: { - project: {}, - loading: 'idle', - }, - auth: { - user: user - } - } + editor: { + project: {}, + loading: "idle", + }, + auth: { + user: user, + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><Header/></MemoryRouter></Provider>); - }) + render( + <Provider store={store}> + <MemoryRouter> + <Header /> + </MemoryRouter> + </Provider>, + ); + }); - test('No project name', () => { - expect(screen.queryByText(project.name)).not.toBeInTheDocument() - }) + test("No project name", () => { + expect(screen.queryByText(project.name)).not.toBeInTheDocument(); + }); - test('No download button', () => { - expect(screen.queryByText('header.download')).not.toBeInTheDocument() - }) + test("No download button", () => { + expect(screen.queryByText("header.download")).not.toBeInTheDocument(); + }); - test('No save button', () => { - expect(screen.queryByText('header.save')).not.toBeInTheDocument() - }) -}) + test("No save button", () => { + expect(screen.queryByText("header.save")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Header/ProjectName.js b/src/components/Header/ProjectName.js index 918989cf8..eeb3f6d6c 100644 --- a/src/components/Header/ProjectName.js +++ b/src/components/Header/ProjectName.js @@ -1,61 +1,70 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux' -import { PencilIcon } from '../../Icons'; -import Button from '../Button/Button'; -import { updateProjectName } from '../Editor/EditorSlice'; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { PencilIcon } from "../../Icons"; +import Button from "../Button/Button"; +import { updateProjectName } from "../Editor/EditorSlice"; -import './ProjectName.scss'; +import "./ProjectName.scss"; const ProjectName = () => { - const project = useSelector((state) => state.editor.project) + const project = useSelector((state) => state.editor.project); const dispatch = useDispatch(); - const { t } = useTranslation() - const nameInput= useRef(); - const [isEditable, setEditable] = useState(false) + const { t } = useTranslation(); + const nameInput = useRef(); + const [isEditable, setEditable] = useState(false); useEffect(() => { if (isEditable) { - nameInput.current.focus() + nameInput.current.focus(); } - }) + }); const updateName = () => { - setEditable(false) - dispatch(updateProjectName(nameInput.current.value)) - } + setEditable(false); + dispatch(updateProjectName(nameInput.current.value)); + }; const onEditNameButtonClick = () => { - setEditable(true) - } + setEditable(true); + }; const handleKeyDown = (event) => { - if (event.key === 'Enter') { - event.preventDefault() - nameInput.current.blur() - } else if (event.key === 'Escape') { - event.preventDefault() - setEditable(false) + if (event.key === "Enter") { + event.preventDefault(); + nameInput.current.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + setEditable(false); } - } + }; return ( - <div className='project-name'> - {isEditable ? - <input - className='project-name__input' - ref={nameInput} - type='text' - onBlur={updateName} - onKeyDown={handleKeyDown} - defaultValue={project.name} /> - : - <> - <h1 className='project-name__title'>{project.name||t('header.newProject')}</h1> - <Button className='btn--tertiary project-name__button' label={t('header.buttonLabel')} title={t('header.buttonTitle')} ButtonIcon={PencilIcon} onClickHandler={onEditNameButtonClick} /> - </> - } + <div className="project-name"> + {isEditable ? ( + <input + className="project-name__input" + ref={nameInput} + type="text" + onBlur={updateName} + onKeyDown={handleKeyDown} + defaultValue={project.name} + /> + ) : ( + <> + <h1 className="project-name__title"> + {project.name || t("header.newProject")} + </h1> + <Button + className="btn--tertiary project-name__button" + label={t("header.buttonLabel")} + title={t("header.buttonTitle")} + ButtonIcon={PencilIcon} + onClickHandler={onEditNameButtonClick} + /> + </> + )} </div> - ) + ); }; export default ProjectName; diff --git a/src/components/Header/ProjectName.test.js b/src/components/Header/ProjectName.test.js index 9712fb3c1..0050ba6ab 100644 --- a/src/components/Header/ProjectName.test.js +++ b/src/components/Header/ProjectName.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import ProjectName from "./ProjectName"; import { updateProjectName } from "../Editor/EditorSlice"; @@ -9,83 +9,89 @@ import { updateProjectName } from "../Editor/EditorSlice"; const project = { identifier: "hello-world-project", name: "Hello world", - user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" -} + user_id: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", +}; -let store -let editButton +let store; +let editButton; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: project, - } - } - store = mockStore(initialState); - render(<Provider store={store}><ProjectName/></Provider>); - editButton = screen.queryByRole('button') -}) - -test('Project name renders in a heading', () => { - expect(screen.queryByText(project.name).tagName).toBe('H1') -}) - -test('Clicking edit button changes the project name to an input field', () => { - fireEvent.click(editButton) - expect(screen.queryByRole('textbox')).toHaveValue(project.name) -}) - -test('Clicking edit button transfers focus to input field', () => { - fireEvent.click(editButton) - expect(screen.queryByRole('textbox')).toHaveFocus() -}) - -describe('When input field loses focus', () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: project, + }, + }; + store = mockStore(initialState); + render( + <Provider store={store}> + <ProjectName /> + </Provider>, + ); + editButton = screen.queryByRole("button"); +}); + +test("Project name renders in a heading", () => { + expect(screen.queryByText(project.name).tagName).toBe("H1"); +}); + +test("Clicking edit button changes the project name to an input field", () => { + fireEvent.click(editButton); + expect(screen.queryByRole("textbox")).toHaveValue(project.name); +}); + +test("Clicking edit button transfers focus to input field", () => { + fireEvent.click(editButton); + expect(screen.queryByRole("textbox")).toHaveFocus(); +}); + +describe("When input field loses focus", () => { beforeEach(() => { - fireEvent.click(editButton) - const inputField = screen.queryByRole('textbox') - inputField.blur() - }) - - test('Updates project name', () => { - expect(store.getActions()).toEqual([updateProjectName(project.name)]) - }) - - test('Changes project name to heading', async () => { - await waitFor(() => expect(screen.queryByText(project.name).tagName).toBe('H1')) - }) -}) - -describe('When Enter is pressed', () => { + fireEvent.click(editButton); + const inputField = screen.queryByRole("textbox"); + inputField.blur(); + }); + + test("Updates project name", () => { + expect(store.getActions()).toEqual([updateProjectName(project.name)]); + }); + + test("Changes project name to heading", async () => { + await waitFor(() => + expect(screen.queryByText(project.name).tagName).toBe("H1"), + ); + }); +}); + +describe("When Enter is pressed", () => { beforeEach(() => { - fireEvent.click(editButton) - const inputField = screen.queryByRole('textbox') - fireEvent.keyDown(inputField, { key: 'Enter'}) - }) + fireEvent.click(editButton); + const inputField = screen.queryByRole("textbox"); + fireEvent.keyDown(inputField, { key: "Enter" }); + }); - test('Updates project name', () => { - expect(store.getActions()).toEqual([updateProjectName(project.name)]) - }) + test("Updates project name", () => { + expect(store.getActions()).toEqual([updateProjectName(project.name)]); + }); - test('Changes project name to heading', () => { - expect(screen.queryByText(project.name).tagName).toBe('H1') - }) -}) + test("Changes project name to heading", () => { + expect(screen.queryByText(project.name).tagName).toBe("H1"); + }); +}); -describe('When Escape is pressed', () => { +describe("When Escape is pressed", () => { beforeEach(() => { - fireEvent.click(editButton) - const inputField = screen.queryByRole('textbox') - fireEvent.keyDown(inputField, { key: 'Escape'}) - }) - - test('Updates project name', () => { - expect(store.getActions()).toEqual([]) - }) - - test('Changes project name to heading', () => { - expect(screen.queryByText(project.name).tagName).toBe('H1') - }) -}) + fireEvent.click(editButton); + const inputField = screen.queryByRole("textbox"); + fireEvent.keyDown(inputField, { key: "Escape" }); + }); + + test("Updates project name", () => { + expect(store.getActions()).toEqual([]); + }); + + test("Changes project name to heading", () => { + expect(screen.queryByText(project.name).tagName).toBe("H1"); + }); +}); diff --git a/src/components/LocaleLayout/LocaleLayout.test.js b/src/components/LocaleLayout/LocaleLayout.test.js index 6f9ac422e..b28108817 100644 --- a/src/components/LocaleLayout/LocaleLayout.test.js +++ b/src/components/LocaleLayout/LocaleLayout.test.js @@ -38,7 +38,7 @@ describe("When locale is allowed", () => { render( <MemoryRouter> <LocaleLayout /> - </MemoryRouter> + </MemoryRouter>, ); }); @@ -75,13 +75,13 @@ describe("When locale is not allowed", () => { render( <MemoryRouter> <LocaleLayout /> - </MemoryRouter> + </MemoryRouter>, ); }); test("Redirects to default language", () => { expect(mockNavigate).toHaveBeenCalledWith( - "/default/projects/my-amazing-project" + "/default/projects/my-amazing-project", ); }); }); diff --git a/src/components/Login/LoginButton.js b/src/components/Login/LoginButton.js index c12c93c95..d897532fc 100644 --- a/src/components/Login/LoginButton.js +++ b/src/components/Login/LoginButton.js @@ -1,23 +1,29 @@ -import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { useSelector } from 'react-redux'; -import Button from '../Button/Button'; -import { login } from '../../utils/login'; +import React from "react"; +import { useLocation } from "react-router-dom"; +import { useSelector } from "react-redux"; +import Button from "../Button/Button"; +import { login } from "../../utils/login"; const LoginButton = (props) => { const { buttonText, className, triggerSave } = props; - const location = useLocation() - const project = useSelector((state) => state.editor.project) - const accessDeniedData = useSelector((state) => state.editor.modals.accessDenied) + const location = useLocation(); + const project = useSelector((state) => state.editor.project); + const accessDeniedData = useSelector( + (state) => state.editor.modals.accessDenied, + ); const onLoginButtonClick = (event) => { event.preventDefault(); - login({project, location, triggerSave, accessDeniedData}) - } + login({ project, location, triggerSave, accessDeniedData }); + }; return ( - <Button buttonText={buttonText} className={className} onClickHandler={onLoginButtonClick} /> - ) -} + <Button + buttonText={buttonText} + className={className} + onClickHandler={onLoginButtonClick} + /> + ); +}; export default LoginButton; diff --git a/src/components/Login/LoginButton.test.js b/src/components/Login/LoginButton.test.js index d5bd33e6d..28b8892bc 100644 --- a/src/components/Login/LoginButton.test.js +++ b/src/components/Login/LoginButton.test.js @@ -1,144 +1,170 @@ import React from "react"; import { MemoryRouter } from "react-router-dom"; -import configureStore from 'redux-mock-store'; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; +import configureStore from "redux-mock-store"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; import userManager from "../../utils/userManager"; import LoginButton from "./LoginButton"; jest.mock("../../utils/userManager", () => ({ - signinRedirect: jest.fn() -})) + signinRedirect: jest.fn(), +})); const project = { components: [ { - name: 'main', - extension: 'py', - content: 'print("hello world")' - } - ] -} + name: "main", + extension: "py", + content: 'print("hello world")', + }, + ], +}; let loginButton; -describe('When accessDeniedData is false', () => { +describe("When accessDeniedData is false", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - modals: {} + modals: {}, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/my_project']}><Provider store={store}><LoginButton buttonText='Login' /></Provider></MemoryRouter>) - loginButton = screen.queryByText('Login') - }) + render( + <MemoryRouter initialEntries={["/my_project"]}> + <Provider store={store}> + <LoginButton buttonText="Login" /> + </Provider> + </MemoryRouter>, + ); + loginButton = screen.queryByText("Login"); + }); test("Login button shown", () => { - expect(loginButton).toBeInTheDocument() - }) + expect(loginButton).toBeInTheDocument(); + }); test("Clicking login button signs the user in", () => { - fireEvent.click(loginButton) - expect(userManager.signinRedirect).toHaveBeenCalled() - }) + fireEvent.click(loginButton); + expect(userManager.signinRedirect).toHaveBeenCalled(); + }); test("Clicking login button saves the user's project content in local storage", () => { - fireEvent.click(loginButton) - expect(localStorage.getItem('project')).toBe(JSON.stringify(project)) - }) + fireEvent.click(loginButton); + expect(localStorage.getItem("project")).toBe(JSON.stringify(project)); + }); test("Clicking login button saves user's location to local storage", () => { - fireEvent.click(loginButton) - expect(localStorage.getItem('location')).toBe('/my_project') - }) -}) + fireEvent.click(loginButton); + expect(localStorage.getItem("location")).toBe("/my_project"); + }); +}); -describe('When accessDeniedData is true', () => { +describe("When accessDeniedData is true", () => { beforeEach(() => { - project.identifier = 'hello-world-project' - project.projectType = 'python' + project.identifier = "hello-world-project"; + project.projectType = "python"; - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, modals: { accessDenied: { identifier: project.identifier, - projectType: project.projectType - } - } + projectType: project.projectType, + }, + }, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/hello-world-project']}><Provider store={store}><LoginButton buttonText='Login' /></Provider></MemoryRouter>) - loginButton = screen.queryByText('Login') - }) + render( + <MemoryRouter initialEntries={["/hello-world-project"]}> + <Provider store={store}> + <LoginButton buttonText="Login" /> + </Provider> + </MemoryRouter>, + ); + loginButton = screen.queryByText("Login"); + }); test("Clicking the login button saves user's location to local storage", () => { - fireEvent.click(loginButton) - expect(localStorage.getItem('location')).toBe('/projects/hello-world-project') - }) -}) + fireEvent.click(loginButton); + expect(localStorage.getItem("location")).toBe( + "/projects/hello-world-project", + ); + }); +}); -describe('When login button has triggerSave set', () => { +describe("When login button has triggerSave set", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - modals: {} + modals: {}, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/my_project']}><Provider store={store}><LoginButton buttonText='Login' triggerSave/></Provider></MemoryRouter>) - loginButton = screen.queryByText('Login') - }) + render( + <MemoryRouter initialEntries={["/my_project"]}> + <Provider store={store}> + <LoginButton buttonText="Login" triggerSave /> + </Provider> + </MemoryRouter>, + ); + loginButton = screen.queryByText("Login"); + }); test("Clicking login button sets 'awaitingSave' in local storage", () => { - fireEvent.click(loginButton) - expect(localStorage.getItem('awaitingSave')).toBe('true') - }) -}) + fireEvent.click(loginButton); + expect(localStorage.getItem("awaitingSave")).toBe("true"); + }); +}); -describe('When login button does not have triggerSave set', () => { +describe("When login button does not have triggerSave set", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: project, - modals: {} + modals: {}, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/my_project']}><Provider store={store}><LoginButton buttonText='Login'/></Provider></MemoryRouter>) - loginButton = screen.queryByText('Login') - }) + render( + <MemoryRouter initialEntries={["/my_project"]}> + <Provider store={store}> + <LoginButton buttonText="Login" /> + </Provider> + </MemoryRouter>, + ); + loginButton = screen.queryByText("Login"); + }); test("Clicking login button does not set 'awaitingSave' in local storage", () => { - fireEvent.click(loginButton) - expect(localStorage.getItem('awaitingSave')).toBeNull() - }) -}) + fireEvent.click(loginButton); + expect(localStorage.getItem("awaitingSave")).toBeNull(); + }); +}); afterEach(() => { - localStorage.clear() -}) + localStorage.clear(); +}); diff --git a/src/components/Login/LoginMenu.js b/src/components/Login/LoginMenu.js index 8a888beef..af5a67abb 100644 --- a/src/components/Login/LoginMenu.js +++ b/src/components/Login/LoginMenu.js @@ -3,26 +3,35 @@ import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import LogoutButton from "./LogoutButton"; import LoginButton from "./LoginButton"; -import './LoginMenu.scss' +import "./LoginMenu.scss"; const LoginMenu = () => { - - const { t } = useTranslation() - const user = useSelector((state) => state.auth.user) + const { t } = useTranslation(); + const user = useSelector((state) => state.auth.user); return ( - <div className = 'dropdown-container dropdown-container--bottom dropdown-container--list login-menu'> - {user !== null ? - <> - <a className='dropdown-container--list__item' href={`${user.profile.profile}/edit`}>{t('globalNav.accountMenu.profile')}</a> - <a className='dropdown-container--list__item' href='/projects'>{t('globalNav.accountMenu.projects')}</a> - <LogoutButton className='btn--tertiary dropdown-container--list__item' /> - </> - : - <LoginButton buttonText={t('globalNav.accountMenu.login')} className='btn--tertiary dropdown-container--list__item'/> - } + <div className="dropdown-container dropdown-container--bottom dropdown-container--list login-menu"> + {user !== null ? ( + <> + <a + className="dropdown-container--list__item" + href={`${user.profile.profile}/edit`} + > + {t("globalNav.accountMenu.profile")} + </a> + <a className="dropdown-container--list__item" href="/projects"> + {t("globalNav.accountMenu.projects")} + </a> + <LogoutButton className="btn--tertiary dropdown-container--list__item" /> + </> + ) : ( + <LoginButton + buttonText={t("globalNav.accountMenu.login")} + className="btn--tertiary dropdown-container--list__item" + /> + )} </div> - ) -} + ); +}; -export default LoginMenu +export default LoginMenu; diff --git a/src/components/Login/LoginMenu.test.js b/src/components/Login/LoginMenu.test.js index 7f8484bd2..b8b145a35 100644 --- a/src/components/Login/LoginMenu.test.js +++ b/src/components/Login/LoginMenu.test.js @@ -1,71 +1,94 @@ import React from "react"; -import { render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import { MemoryRouter } from "react-router-dom"; import LoginMenu from "./LoginMenu"; -describe('When not logged in', () => { - +describe("When not logged in", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: {}, - modals: {} + modals: {}, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/']}><Provider store={store}><LoginMenu/></Provider></MemoryRouter>); - }) - - test('Login button renders', () => { - expect(screen.queryByText('globalNav.accountMenu.login')).toBeInTheDocument() - }) + render( + <MemoryRouter initialEntries={["/"]}> + <Provider store={store}> + <LoginMenu /> + </Provider> + </MemoryRouter>, + ); + }); - test('My profile does not render', () => { - expect(screen.queryByText('globalNav.accountMenu.profile')).not.toBeInTheDocument() - }) + test("Login button renders", () => { + expect( + screen.queryByText("globalNav.accountMenu.login"), + ).toBeInTheDocument(); + }); - test('My projects does not render', () => { - expect(screen.queryByText('globalNav.accountMenu.projects')).not.toBeInTheDocument() - }) -}) + test("My profile does not render", () => { + expect( + screen.queryByText("globalNav.accountMenu.profile"), + ).not.toBeInTheDocument(); + }); -describe('When logged in', () => { + test("My projects does not render", () => { + expect( + screen.queryByText("globalNav.accountMenu.projects"), + ).not.toBeInTheDocument(); + }); +}); +describe("When logged in", () => { beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - project: {} + project: {}, }, auth: { user: { profile: { - profile: 'profile_url' - } - } - } - } + profile: "profile_url", + }, + }, + }, + }; const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/']}><Provider store={store}><LoginMenu/></Provider></MemoryRouter>); - }) + render( + <MemoryRouter initialEntries={["/"]}> + <Provider store={store}> + <LoginMenu /> + </Provider> + </MemoryRouter>, + ); + }); - test('Logout button renders', () => { - expect(screen.queryByText('globalNav.accountMenu.logout')).toBeInTheDocument() - }) + test("Logout button renders", () => { + expect( + screen.queryByText("globalNav.accountMenu.logout"), + ).toBeInTheDocument(); + }); - test('My profile renders with correct link', () => { - expect(screen.queryByText('globalNav.accountMenu.profile')).toHaveAttribute('href', 'profile_url/edit') - }) + test("My profile renders with correct link", () => { + expect(screen.queryByText("globalNav.accountMenu.profile")).toHaveAttribute( + "href", + "profile_url/edit", + ); + }); - test('My projects renders with correct link', () => { - expect(screen.queryByText('globalNav.accountMenu.projects')).toHaveAttribute('href', '/projects') - }) -}) + test("My projects renders with correct link", () => { + expect( + screen.queryByText("globalNav.accountMenu.projects"), + ).toHaveAttribute("href", "/projects"); + }); +}); diff --git a/src/components/Login/LogoutButton.js b/src/components/Login/LogoutButton.js index d148e5771..f8adbc770 100644 --- a/src/components/Login/LogoutButton.js +++ b/src/components/Login/LogoutButton.js @@ -1,24 +1,28 @@ -import React from 'react'; -import userManager from '../../utils/userManager' -import { useTranslation } from 'react-i18next'; -import Button from '../Button/Button'; -import { useNavigate } from 'react-router-dom'; +import React from "react"; +import userManager from "../../utils/userManager"; +import { useTranslation } from "react-i18next"; +import Button from "../Button/Button"; +import { useNavigate } from "react-router-dom"; const LogoutButton = (props) => { const { className } = props; - const { t } = useTranslation() - const navigate = useNavigate() + const { t } = useTranslation(); + const navigate = useNavigate(); const onLogoutButtonClick = async (event) => { event.preventDefault(); - await userManager.removeUser() - localStorage.clear() - navigate('/') - } + await userManager.removeUser(); + localStorage.clear(); + navigate("/"); + }; return ( - <Button buttonText={t('globalNav.accountMenu.logout')} className={className} onClickHandler={onLogoutButtonClick} /> - ) -} + <Button + buttonText={t("globalNav.accountMenu.logout")} + className={className} + onClickHandler={onLogoutButtonClick} + /> + ); +}; export default LogoutButton; diff --git a/src/components/Login/LogoutButton.test.js b/src/components/Login/LogoutButton.test.js index f723d4484..33a19bbff 100644 --- a/src/components/Login/LogoutButton.test.js +++ b/src/components/Login/LogoutButton.test.js @@ -1,38 +1,44 @@ import React from "react"; import { MemoryRouter } from "react-router-dom"; -import configureStore from 'redux-mock-store'; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; +import configureStore from "redux-mock-store"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; import userManager from "../../utils/userManager"; import LogoutButton from "./LogoutButton"; jest.mock("../../utils/userManager", () => ({ - removeUser: jest.fn() -})) + removeUser: jest.fn(), +})); let logoutButton; - beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) - const initialState = { - editor: { - project: {} - }, - auth: { - user: {} - } - } - const store = mockStore(initialState); - render(<MemoryRouter initialEntries={['/']}><Provider store={store}><LogoutButton /></Provider></MemoryRouter>) - logoutButton = screen.queryByText('globalNav.accountMenu.logout') - }) +beforeEach(() => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: {}, + }, + auth: { + user: {}, + }, + }; + const store = mockStore(initialState); + render( + <MemoryRouter initialEntries={["/"]}> + <Provider store={store}> + <LogoutButton /> + </Provider> + </MemoryRouter>, + ); + logoutButton = screen.queryByText("globalNav.accountMenu.logout"); +}); - test("Log out button shown", () => { - expect(logoutButton).toBeInTheDocument() - }) +test("Log out button shown", () => { + expect(logoutButton).toBeInTheDocument(); +}); - test("Clicking log out button signs the user out", () => { - fireEvent.click(logoutButton) - expect(userManager.removeUser).toHaveBeenCalled() - }) +test("Clicking log out button signs the user out", () => { + fireEvent.click(logoutButton); + expect(userManager.removeUser).toHaveBeenCalled(); +}); diff --git a/src/components/Menus/ContextMenu/ContextMenu.js b/src/components/Menus/ContextMenu/ContextMenu.js index 641666ca1..31f8dc9ae 100644 --- a/src/components/Menus/ContextMenu/ContextMenu.js +++ b/src/components/Menus/ContextMenu/ContextMenu.js @@ -2,64 +2,79 @@ import React, { useContext, useRef, useState } from "react"; import { ControlledMenu, MenuItem } from "@szhsin/react-menu"; import { SettingsContext } from "../../../settings"; -import './ContextMenu.scss' +import "./ContextMenu.scss"; const ContextMenu = (props) => { - - const { align, direction, menuButtonLabel, menuButtonClassName, MenuButtonIcon, menuOptions, offsetX, offsetY,} = props - const settings = useContext(SettingsContext) - const menuButton = useRef(null) - const contextMenu = useRef() - const [isOpen, setOpen] = useState(false) + const { + align, + direction, + menuButtonLabel, + menuButtonClassName, + MenuButtonIcon, + menuOptions, + offsetX, + offsetY, + } = props; + const settings = useContext(SettingsContext); + const menuButton = useRef(null); + const contextMenu = useRef(); + const [isOpen, setOpen] = useState(false); const setMenuOpenState = (isMenuOpen) => { - setOpen(isMenuOpen) + setOpen(isMenuOpen); if (isMenuOpen) { - const hiddenDiv = contextMenu.current.firstChild - hiddenDiv.setAttribute('role', 'menuitem') - hiddenDiv.setAttribute('aria-hidden', 'true') + const hiddenDiv = contextMenu.current.firstChild; + hiddenDiv.setAttribute("role", "menuitem"); + hiddenDiv.setAttribute("aria-hidden", "true"); } else { - menuButton.current.focus() + menuButton.current.focus(); } - } + }; return ( <> <button aria-haspopup="menu" aria-label={menuButtonLabel} - className = {`btn btn-tertiary context-menu__drop${menuButtonClassName ? ` ${menuButtonClassName}` : ''}`} + className={`btn btn-tertiary context-menu__drop${ + menuButtonClassName ? ` ${menuButtonClassName}` : "" + }`} title={menuButtonLabel} type="button" ref={menuButton} onClick={() => setMenuOpenState(true)} > - <MenuButtonIcon/> + <MenuButtonIcon /> </button> <ControlledMenu transition align={align} direction={direction} - menuStyle={{padding: '5px'}} + menuStyle={{ padding: "5px" }} offsetX={offsetX} offsetY={offsetY} - position='anchor' - viewScroll='initial' + position="anchor" + viewScroll="initial" portal={true} menuClassName={`context-menu context-menu--${settings.theme}`} - menuItemFocus={{position: 'first'}} - state={isOpen ? 'open' : 'closed'} + menuItemFocus={{ position: "first" }} + state={isOpen ? "open" : "closed"} anchorRef={menuButton} ref={contextMenu} onClose={() => setMenuOpenState(false)} > - {menuOptions.map((option, i) => ( - <MenuItem key={i} className='btn context-menu__item' onClick={option.action} > - <option.icon/> {option.text} - </MenuItem> - ))} - </ControlledMenu> - </> - ) -} + {menuOptions.map((option, i) => ( + <MenuItem + key={i} + className="btn context-menu__item" + onClick={option.action} + > + <option.icon /> + {option.text} + </MenuItem> + ))} + </ControlledMenu> + </> + ); +}; -export default ContextMenu +export default ContextMenu; diff --git a/src/components/Menus/ContextMenu/ContextMenu.test.js b/src/components/Menus/ContextMenu/ContextMenu.test.js index a097291ec..f14830fe2 100644 --- a/src/components/Menus/ContextMenu/ContextMenu.test.js +++ b/src/components/Menus/ContextMenu/ContextMenu.test.js @@ -1,50 +1,49 @@ -import React from 'react' -import { fireEvent, render, screen } from '@testing-library/react' -import { axe, toHaveNoViolations }from 'jest-axe' -import ContextMenu from './ContextMenu' +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; +import ContextMenu from "./ContextMenu"; -expect.extend(toHaveNoViolations) -const action1 = jest.fn() +expect.extend(toHaveNoViolations); +const action1 = jest.fn(); describe("With file items", () => { - beforeEach(() => { render( <ContextMenu - MenuButtonIcon = {() => {}} - menuButtonLabel = 'button' - menuOptions={[{text: 'option1', action: action1, icon: () => {}}]} - /> - ) - }) + MenuButtonIcon={() => {}} + menuButtonLabel="button" + menuOptions={[{ text: "option1", action: action1, icon: () => {} }]} + />, + ); + }); test("Menu is not visible initially", () => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); test("Clicking button makes menu content appear", () => { - const button = screen.getByRole('button') - fireEvent.click(button) - expect(screen.queryByRole('menu')).toBeInTheDocument() - }) + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(screen.queryByRole("menu")).toBeInTheDocument(); + }); test("Clicking option button calls action", () => { - const button = screen.getByRole('button') - fireEvent.click(button) - const menuOption = screen.queryByText('option1') - fireEvent.click(menuOption) - expect(action1).toHaveBeenCalled() - }) + const button = screen.getByRole("button"); + fireEvent.click(button); + const menuOption = screen.queryByText("option1"); + fireEvent.click(menuOption); + expect(action1).toHaveBeenCalled(); + }); test("It passes AXE accessibility testing when menu is closed", async () => { - const axeResults = await axe(screen.queryByRole("button")) - expect(axeResults).toHaveNoViolations() - }) + const axeResults = await axe(screen.queryByRole("button")); + expect(axeResults).toHaveNoViolations(); + }); test("It passes AXE accessibility testing when menu is open", async () => { - const button = screen.getByRole('button') - fireEvent.click(button) - const axeResults = await axe(screen.queryByRole("menu")) - expect(axeResults).toHaveNoViolations() - }) -}) + const button = screen.getByRole("button"); + fireEvent.click(button); + const axeResults = await axe(screen.queryByRole("menu")); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/src/components/Menus/Dropdown/Dropdown.js b/src/components/Menus/Dropdown/Dropdown.js index 8f432ad40..34e201bbe 100644 --- a/src/components/Menus/Dropdown/Dropdown.js +++ b/src/components/Menus/Dropdown/Dropdown.js @@ -1,20 +1,26 @@ import React, { useEffect, useRef, useState } from "react"; import Button from "../../Button/Button"; -import './Dropdown.scss' +import "./Dropdown.scss"; const Dropdown = (props) => { - const {ButtonIcon, buttonImage, buttonImageAltText, buttonText, MenuContent} = props - const [isOpen, setOpen] = useState(false) - const dropdown = useRef() - + const { + ButtonIcon, + buttonImage, + buttonImageAltText, + buttonText, + MenuContent, + } = props; + const [isOpen, setOpen] = useState(false); + const dropdown = useRef(); + useEffect(() => { /** * Close menu if clicked outside of element */ function handleClickOutside(event) { if (dropdown.current && !dropdown.current.contains(event.target)) { - setOpen(false) + setOpen(false); } } // Bind the event listener @@ -26,29 +32,35 @@ const Dropdown = (props) => { }, [dropdown]); const handleKeyDown = (event) => { - if (event.key === 'Escape'){ + if (event.key === "Escape") { setOpen(false); } - } + }; return ( - <div className='dropdown' ref={dropdown} onKeyDown={handleKeyDown}> + <div className="dropdown" ref={dropdown} onKeyDown={handleKeyDown}> <Button - className={`btn--tertiary dropdown-button${isOpen ? ' dropdown-button--active' : ''}`} + className={`btn--tertiary dropdown-button${ + isOpen ? " dropdown-button--active" : "" + }`} onClickHandler={() => setOpen(!isOpen)} buttonText={buttonText} ButtonIcon={ButtonIcon} buttonImage={buttonImage} buttonImageAltText={buttonImageAltText} /> - - {isOpen ? - <> - <div className='dropdown-backdrop' onClick={() => setOpen(false)}></div> - <MenuContent /> - </> : null} + + {isOpen ? ( + <> + <div + className="dropdown-backdrop" + onClick={() => setOpen(false)} + ></div> + <MenuContent /> + </> + ) : null} </div> - ) -} + ); +}; -export default Dropdown +export default Dropdown; diff --git a/src/components/Menus/Dropdown/Dropdown.test.js b/src/components/Menus/Dropdown/Dropdown.test.js index 20f995715..510967268 100644 --- a/src/components/Menus/Dropdown/Dropdown.test.js +++ b/src/components/Menus/Dropdown/Dropdown.test.js @@ -5,47 +5,50 @@ import Dropdown from "./Dropdown"; const buttonIcon = () => { return ( - <svg><title>my icon</title></svg> - ) -} + <svg> + <title>my icon</title> + </svg> + ); +}; const MenuContent = () => { - return ( - <h1>Menu</h1> - ) -} + return <h1>Menu</h1>; +}; let queryByTitle; let queryByText; let getByText; let queryByRole; beforeEach(() => { - ({getByText, queryByRole, queryByText, queryByTitle} = render(<Dropdown - ButtonIcon={buttonIcon} - buttonText='my button' - MenuContent={MenuContent}/>)) -}) + ({ getByText, queryByRole, queryByText, queryByTitle } = render( + <Dropdown + ButtonIcon={buttonIcon} + buttonText="my button" + MenuContent={MenuContent} + />, + )); +}); test("Button icon renders", () => { - expect(queryByTitle('my icon')).not.toBeNull() -}) + expect(queryByTitle("my icon")).not.toBeNull(); +}); test("Button text renders", () => { - expect(queryByText('my button')).not.toBeNull() -}) + expect(queryByText("my button")).not.toBeNull(); +}); test("Menu content not disable intially", () => { - expect(queryByRole('heading', {level: 1, name: "Menu"})).toBeNull() -}) + expect(queryByRole("heading", { level: 1, name: "Menu" })).toBeNull(); +}); test("Clicking button makes menu content appear", () => { - const button = getByText('my button').parentElement - fireEvent.click(button) - expect(queryByRole('heading', {level: 1, name: "Menu"})).not.toBeNull() -}) + const button = getByText("my button").parentElement; + fireEvent.click(button); + expect(queryByRole("heading", { level: 1, name: "Menu" })).not.toBeNull(); +}); test("Clicking outside menu makes it close", () => { - const button = getByText('my button').parentElement - fireEvent.click(button) - userEvent.click(document.body) - expect(queryByRole('heading', {level: 1, name: "Menu"})).toBeNull() -}) + const button = getByText("my button").parentElement; + fireEvent.click(button); + userEvent.click(document.body); + expect(queryByRole("heading", { level: 1, name: "Menu" })).toBeNull(); +}); diff --git a/src/components/Menus/FileMenu/FileMenu.js b/src/components/Menus/FileMenu/FileMenu.js index 6fbecdc5d..cdd4ca74f 100644 --- a/src/components/Menus/FileMenu/FileMenu.js +++ b/src/components/Menus/FileMenu/FileMenu.js @@ -1,38 +1,38 @@ -import React from "react" -import { useDispatch } from 'react-redux' +import React from "react"; +import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; -import { showRenameFileModal } from '../../Editor/EditorSlice' -import { EllipsisVerticalIcon, PencilIcon } from '../../../Icons'; +import { showRenameFileModal } from "../../Editor/EditorSlice"; +import { EllipsisVerticalIcon, PencilIcon } from "../../../Icons"; import ContextMenu from "../ContextMenu/ContextMenu"; const FileMenu = (props) => { - const dispatch = useDispatch() - const { t } = useTranslation() + const dispatch = useDispatch(); + const { t } = useTranslation(); const onClickRenameFile = () => { - dispatch(showRenameFileModal(props)) - } + dispatch(showRenameFileModal(props)); + }; return ( - <div onClick = {(e) => e.stopPropagation()}> + <div onClick={(e) => e.stopPropagation()}> <ContextMenu - align = 'start' - direction = 'right' - menuButtonLabel={t('filePane.fileMenu.label')} - MenuButtonIcon = {EllipsisVerticalIcon} - menuOptions = {[ + align="start" + direction="right" + menuButtonLabel={t("filePane.fileMenu.label")} + MenuButtonIcon={EllipsisVerticalIcon} + menuOptions={[ { icon: PencilIcon, - text: t('filePane.fileMenu.renameItem'), - action: onClickRenameFile - } + text: t("filePane.fileMenu.renameItem"), + action: onClickRenameFile, + }, ]} offsetX={15} offsetY={-10} /> </div> - ) -} - -export default FileMenu + ); +}; + +export default FileMenu; diff --git a/src/components/Menus/FileMenu/FileMenu.test.js b/src/components/Menus/FileMenu/FileMenu.test.js index e55ba2338..a6c242739 100644 --- a/src/components/Menus/FileMenu/FileMenu.test.js +++ b/src/components/Menus/FileMenu/FileMenu.test.js @@ -1,66 +1,68 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { Provider } from 'react-redux' -import { MemoryRouter } from 'react-router-dom' -import configureStore from 'redux-mock-store' +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { MemoryRouter } from "react-router-dom"; +import configureStore from "redux-mock-store"; -import FileMenu from './FileMenu' -import { showRenameFileModal } from '../../Editor/EditorSlice' -import { SettingsContext } from '../../../settings' +import FileMenu from "./FileMenu"; +import { showRenameFileModal } from "../../Editor/EditorSlice"; +import { SettingsContext } from "../../../settings"; describe("with file item", () => { let store; beforeEach(() => { - const mockStore = configureStore([]) + const mockStore = configureStore([]); const initialState = { editor: { project: { components: [], - imageList: [] + imageList: [], }, isEmbedded: false, renameFileModalShowing: false, modals: {}, - } - } - store = mockStore(initialState) + }, + }; + store = mockStore(initialState); render( - <MemoryRouter initialEntries={['/']}> + <MemoryRouter initialEntries={["/"]}> <Provider store={store}> - <SettingsContext.Provider value={{ theme: 'dark', fontSize: 'small' }}> + <SettingsContext.Provider + value={{ theme: "dark", fontSize: "small" }} + > <div id="app"> - <FileMenu fileKey={0} name={'file1'} ext={'py'} /> + <FileMenu fileKey={0} name={"file1"} ext={"py"} /> </div> </SettingsContext.Provider> </Provider> - </MemoryRouter> - ) - }) + </MemoryRouter>, + ); + }); test("Menu is not visible initially", () => { - expect(screen.queryByRole('menu')).toBeNull() - }) + expect(screen.queryByRole("menu")).toBeNull(); + }); test("Clicking button makes menu content appear", () => { - const button = screen.getByRole('button') - fireEvent.click(button) - expect(screen.queryByRole('menu')).not.toBeNull() - }) + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(screen.queryByRole("menu")).not.toBeNull(); + }); test("All file functions are listed", () => { - const button = screen.getByRole('button') - fireEvent.click(button) - expect(screen.getByText('filePane.fileMenu.renameItem')).not.toBeNull() - }) + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(screen.getByText("filePane.fileMenu.renameItem")).not.toBeNull(); + }); test("Clicking rename dispatches modal show with file details", () => { - const menuButton = screen.getByRole('button') - fireEvent.click(menuButton) - const renameButton = screen.getByText('filePane.fileMenu.renameItem') - fireEvent.click(renameButton) + const menuButton = screen.getByRole("button"); + fireEvent.click(menuButton); + const renameButton = screen.getByText("filePane.fileMenu.renameItem"); + fireEvent.click(renameButton); const expectedActions = [ - showRenameFileModal({fileKey: 0, ext: "py", name: "file1"}) - ] + showRenameFileModal({ fileKey: 0, ext: "py", name: "file1" }), + ]; expect(store.getActions()).toEqual(expectedActions); - }) -}) + }); +}); diff --git a/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.js b/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.js index 43b2d1c0d..02cc07b4e 100644 --- a/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.js +++ b/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.js @@ -2,44 +2,47 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; import { BinIcon, EllipsisVerticalIcon, PencilIcon } from "../../../Icons"; -import { showDeleteProjectModal, showRenameProjectModal } from "../../Editor/EditorSlice"; +import { + showDeleteProjectModal, + showRenameProjectModal, +} from "../../Editor/EditorSlice"; import ContextMenu from "../ContextMenu/ContextMenu"; const ProjectActionsMenu = (props) => { - const { project } = props - const { t } = useTranslation() - const dispatch = useDispatch() + const { project } = props; + const { t } = useTranslation(); + const dispatch = useDispatch(); const openRenameProjectModal = () => { - dispatch(showRenameProjectModal(project)) - } + dispatch(showRenameProjectModal(project)); + }; const openDeleteProjectModal = () => { - dispatch(showDeleteProjectModal(project)) - } + dispatch(showDeleteProjectModal(project)); + }; return ( <ContextMenu - align = 'end' - direction = 'bottom' - menuButtonLabel = {t('projectList.label')} - menuButtonClassName = 'editor-project-list__menu' - MenuButtonIcon = {EllipsisVerticalIcon} - menuOptions = {[ + align="end" + direction="bottom" + menuButtonLabel={t("projectList.label")} + menuButtonClassName="editor-project-list__menu" + MenuButtonIcon={EllipsisVerticalIcon} + menuOptions={[ { icon: PencilIcon, - text: t('projectList.rename'), - action: openRenameProjectModal + text: t("projectList.rename"), + action: openRenameProjectModal, }, { icon: BinIcon, - text: t('projectList.delete'), - action: openDeleteProjectModal - } + text: t("projectList.delete"), + action: openDeleteProjectModal, + }, ]} offsetX={-10} /> - ) -} + ); +}; -export default ProjectActionsMenu +export default ProjectActionsMenu; diff --git a/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.test.js b/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.test.js index 43bdd71c6..02d8c0bb4 100644 --- a/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.test.js +++ b/src/components/Menus/ProjectActionsMenu/ProjectActionsMenu.test.js @@ -2,40 +2,54 @@ import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { Provider } from "react-redux"; import ProjectActionsMenu from "./ProjectActionsMenu"; -import configureStore from 'redux-mock-store'; +import configureStore from "redux-mock-store"; -let store +let store; beforeEach(() => { - const mockStore = configureStore([]) - const initialState = {} + const mockStore = configureStore([]); + const initialState = {}; store = mockStore(initialState); - render(<Provider store={store}><ProjectActionsMenu project = {{name: 'my amazing project'}}/></Provider>) -}) + render( + <Provider store={store}> + <ProjectActionsMenu project={{ name: "my amazing project" }} /> + </Provider>, + ); +}); test("Menu is not visible initially", () => { - expect(screen.queryByRole('menu')).toBeNull() -}) + expect(screen.queryByRole("menu")).toBeNull(); +}); test("Clicking button makes menu content appear", () => { - const button = screen.getByRole('button') - fireEvent.click(button) - expect(screen.queryByRole('menu')).not.toBeNull() -}) + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(screen.queryByRole("menu")).not.toBeNull(); +}); -test('Clicking rename option opens the rename project modal', () => { - const button = screen.getByRole('button') - fireEvent.click(button) - const renameOption = screen.getByText('projectList.rename') - fireEvent.click(renameOption) - expect(store.getActions()).toEqual([{type: "editor/showRenameProjectModal", payload: {"name": "my amazing project"}}]) -}) +test("Clicking rename option opens the rename project modal", () => { + const button = screen.getByRole("button"); + fireEvent.click(button); + const renameOption = screen.getByText("projectList.rename"); + fireEvent.click(renameOption); + expect(store.getActions()).toEqual([ + { + type: "editor/showRenameProjectModal", + payload: { name: "my amazing project" }, + }, + ]); +}); -test('Clicking delete option opens the delete project modal', () => { - const button = screen.getByRole('button') - fireEvent.click(button) - const deleteOption = screen.getByText('projectList.delete') - fireEvent.click(deleteOption) - expect(store.getActions()).toEqual([{type: "editor/showDeleteProjectModal", payload: {"name": "my amazing project"}}]) -}) +test("Clicking delete option opens the delete project modal", () => { + const button = screen.getByRole("button"); + fireEvent.click(button); + const deleteOption = screen.getByText("projectList.delete"); + fireEvent.click(deleteOption); + expect(store.getActions()).toEqual([ + { + type: "editor/showDeleteProjectModal", + payload: { name: "my amazing project" }, + }, + ]); +}); diff --git a/src/components/Menus/SettingsMenu/SettingsMenu.js b/src/components/Menus/SettingsMenu/SettingsMenu.js index 5e71e7de2..32b1f4a85 100644 --- a/src/components/Menus/SettingsMenu/SettingsMenu.js +++ b/src/components/Menus/SettingsMenu/SettingsMenu.js @@ -2,26 +2,25 @@ import React from "react"; import FontSizeSelector from "../../Editor/FontSizeSelector/FontSizeSelector"; import ThemeToggle from "../../ThemeToggle/ThemeToggle"; -import './SettingsMenu.scss' -import { useTranslation } from 'react-i18next'; +import "./SettingsMenu.scss"; +import { useTranslation } from "react-i18next"; const SettingsMenu = () => { - - const {t} = useTranslation() + const { t } = useTranslation(); return ( - <div className='dropdown-container dropdown-container--bottom settings-menu'> - <h2>{t('header.settingsMenu.heading')}</h2> - <div className='settings-menu__theme'> - <h3>{t('header.settingsMenu.theme')}</h3> + <div className="dropdown-container dropdown-container--bottom settings-menu"> + <h2>{t("header.settingsMenu.heading")}</h2> + <div className="settings-menu__theme"> + <h3>{t("header.settingsMenu.theme")}</h3> <ThemeToggle /> </div> - <div className='settings-menu__font-size'> - <h3>{t('header.settingsMenu.textSize')}</h3> + <div className="settings-menu__font-size"> + <h3>{t("header.settingsMenu.textSize")}</h3> <FontSizeSelector /> </div> </div> - ) -} + ); +}; -export default SettingsMenu +export default SettingsMenu; diff --git a/src/components/Menus/SettingsMenu/SettingsMenu.test.js b/src/components/Menus/SettingsMenu/SettingsMenu.test.js index 9a620c331..7d9daa37c 100644 --- a/src/components/Menus/SettingsMenu/SettingsMenu.test.js +++ b/src/components/Menus/SettingsMenu/SettingsMenu.test.js @@ -1,15 +1,15 @@ import React from "react"; -import { render } from "@testing-library/react" +import { render } from "@testing-library/react"; -import SettingsMenu from './SettingsMenu' +import SettingsMenu from "./SettingsMenu"; test("Renders heading", () => { - const {queryByText} = render(<SettingsMenu />) + const { queryByText } = render(<SettingsMenu />); expect(queryByText("header.settingsMenu.heading")).not.toBeNull(); -}) +}); test("Renders section headings", () => { - const {queryByText} = render(<SettingsMenu />) + const { queryByText } = render(<SettingsMenu />); expect(queryByText("header.settingsMenu.theme")).not.toBeNull(); expect(queryByText("header.settingsMenu.textSize")).not.toBeNull(); -}) +}); diff --git a/src/components/Menus/SideMenu/FilePane/FilePane.js b/src/components/Menus/SideMenu/FilePane/FilePane.js index 02513aa1c..a4b73729a 100644 --- a/src/components/Menus/SideMenu/FilePane/FilePane.js +++ b/src/components/Menus/SideMenu/FilePane/FilePane.js @@ -1,21 +1,22 @@ -import React from "react" -import { useSelector } from "react-redux" -import ProjectImages from "./ProjectImages/ProjectImages" -import FilesList from "./FilesList" +import React from "react"; +import { useSelector } from "react-redux"; +import ProjectImages from "./ProjectImages/ProjectImages"; +import FilesList from "./FilesList"; -import './FilePane.scss' +import "./FilePane.scss"; const FilePane = (props) => { - - const project = useSelector((state) => state.editor.project) - const {openFileTab} = props + const project = useSelector((state) => state.editor.project); + const { openFileTab } = props; return ( - <div className='file-pane'> - <FilesList openFileTab = {openFileTab}/> - {project.image_list && project.image_list.length>0? <ProjectImages /> : null} + <div className="file-pane"> + <FilesList openFileTab={openFileTab} /> + {project.image_list && project.image_list.length > 0 ? ( + <ProjectImages /> + ) : null} </div> - ) -} + ); +}; -export default FilePane +export default FilePane; diff --git a/src/components/Menus/SideMenu/FilePane/FilePane.test.js b/src/components/Menus/SideMenu/FilePane/FilePane.test.js index 500cb1ba0..68c36f4e7 100644 --- a/src/components/Menus/SideMenu/FilePane/FilePane.test.js +++ b/src/components/Menus/SideMenu/FilePane/FilePane.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { render} from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import { MemoryRouter } from "react-router-dom"; import FilePane from "./FilePane"; @@ -10,65 +10,79 @@ describe("When no project images", () => { let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { - components: [] + components: [], }, - isEmbedded: false + isEmbedded: false, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><MemoryRouter><div id="app"><FilePane /></div></MemoryRouter></Provider>)) - }) - + ({ queryByText } = render( + <Provider store={store}> + <MemoryRouter> + <div id="app"> + <FilePane /> + </div> + </MemoryRouter> + </Provider>, + )); + }); test("Renders project files section", () => { - expect(queryByText("filePane.files")).not.toBeNull() - }) + expect(queryByText("filePane.files")).not.toBeNull(); + }); test("No project images section", () => { - expect(queryByText("filePane.images")).toBeNull() - }) -}) + expect(queryByText("filePane.images")).toBeNull(); + }); +}); describe("When project images", () => { let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [], image_list: [ { - filename: "hello_world.png" - } - ] + filename: "hello_world.png", + }, + ], }, - isEmbedded: false + isEmbedded: false, }, auth: { - user: null - } - } + user: null, + }, + }; const store = mockStore(initialState); - ({queryByText} = render(<Provider store={store}><MemoryRouter><div id="app"><FilePane /></div></MemoryRouter></Provider>)) - }) - + ({ queryByText } = render( + <Provider store={store}> + <MemoryRouter> + <div id="app"> + <FilePane /> + </div> + </MemoryRouter> + </Provider>, + )); + }); test("Renders project files section", () => { - expect(queryByText("filePane.files")).not.toBeNull() - }) + expect(queryByText("filePane.files")).not.toBeNull(); + }); test("Renders project images section", () => { - expect(queryByText("filePane.images")).not.toBeNull() - }) -}) + expect(queryByText("filePane.images")).not.toBeNull(); + }); +}); diff --git a/src/components/Menus/SideMenu/FilePane/FilesList.js b/src/components/Menus/SideMenu/FilePane/FilesList.js index 0447be2ec..b9b438399 100644 --- a/src/components/Menus/SideMenu/FilePane/FilesList.js +++ b/src/components/Menus/SideMenu/FilePane/FilesList.js @@ -1,43 +1,50 @@ import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; -import { ChevronDown, FileIcon } from '../../../../Icons'; -import FileMenu from '../../FileMenu/FileMenu'; +import { ChevronDown, FileIcon } from "../../../../Icons"; +import FileMenu from "../../FileMenu/FileMenu"; import NewComponentButton from "../../../Editor/NewComponentButton/NewComponentButton"; -import './FilesList.scss' +import "./FilesList.scss"; const FilesList = (props) => { - const project = useSelector((state) => state.editor.project) - const {openFileTab} = props - const { t } = useTranslation() + const project = useSelector((state) => state.editor.project); + const { openFileTab } = props; + const { t } = useTranslation(); return ( - <details className = "file-pane-section file-pane-section__files" open> + <details className="file-pane-section file-pane-section__files" open> <summary> - <h2>{t('filePane.files')}</h2> + <h2>{t("filePane.files")}</h2> <div className="accordion-icon"> <ChevronDown /> </div> </summary> <NewComponentButton /> - <div className='files-list'> - { project.components.map((file, i) => ( - <div className='files-list-item' key={i} onClick={() => openFileTab(`${file.name}.${file.extension}`)}> - <div className='files-list-icon'> - <FileIcon ext={file.extension} /> - </div> - <span className='files-list-item__name'>{file.name}.{file.extension}</span> - {((file.name === 'main' && file.extension === 'py') || (file.name === 'index' && file.extension === 'html')) ? null : - <div className='files-list-item__menu'> - <FileMenu fileKey={i} name={file.name} ext={file.extension} /> + <div className="files-list"> + {project.components.map((file, i) => ( + <div + className="files-list-item" + key={i} + onClick={() => openFileTab(`${file.name}.${file.extension}`)} + > + <div className="files-list-icon"> + <FileIcon ext={file.extension} /> </div> - } - </div> - ))} + <span className="files-list-item__name"> + {file.name}.{file.extension} + </span> + {(file.name === "main" && file.extension === "py") || + (file.name === "index" && file.extension === "html") ? null : ( + <div className="files-list-item__menu"> + <FileMenu fileKey={i} name={file.name} ext={file.extension} /> + </div> + )} + </div> + ))} </div> </details> - ) -} + ); +}; -export default FilesList +export default FilesList; diff --git a/src/components/Menus/SideMenu/FilePane/FilesList.test.js b/src/components/Menus/SideMenu/FilePane/FilesList.test.js index 64610d62b..cfc6631c8 100644 --- a/src/components/Menus/SideMenu/FilePane/FilesList.test.js +++ b/src/components/Menus/SideMenu/FilePane/FilesList.test.js @@ -1,115 +1,148 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import FilesList from "./FilesList"; -const openFileTab = jest.fn() +const openFileTab = jest.fn(); -const createMockStore = function(components) { - const mockStore = configureStore([]) +const createMockStore = function (components) { + const mockStore = configureStore([]); return mockStore({ editor: { project: { - components: components + components: components, }, - isEmbedded: false + isEmbedded: false, }, auth: { - user: null - } + user: null, + }, }); -} +}; describe("When project has multiple files", () => { beforeEach(() => { - const store = createMockStore( - [ - { - name: "a", - extension: "py" - }, - { - name: "b", - extension: "html" - }, - { - name: "c", - extension: "css" - }, - { - name: "d", - extension: "csv" - } - ] - ) - render(<Provider store={store}><div id="app"><FilesList openFileTab={openFileTab}/></div></Provider>) - }) + const store = createMockStore([ + { + name: "a", + extension: "py", + }, + { + name: "b", + extension: "html", + }, + { + name: "c", + extension: "css", + }, + { + name: "d", + extension: "csv", + }, + ]); + render( + <Provider store={store}> + <div id="app"> + <FilesList openFileTab={openFileTab} /> + </div> + </Provider>, + ); + }); test("Renders all file names", () => { - expect(screen.queryByText("a.py")).not.toBeNull() - expect(screen.queryByText("b.html")).not.toBeNull() - expect(screen.queryByText("c.css")).not.toBeNull() - expect(screen.queryByText("d.csv")).not.toBeNull() - }) + expect(screen.queryByText("a.py")).not.toBeNull(); + expect(screen.queryByText("b.html")).not.toBeNull(); + expect(screen.queryByText("c.css")).not.toBeNull(); + expect(screen.queryByText("d.csv")).not.toBeNull(); + }); test("Renders a menu button for each file", () => { - expect(screen.getAllByTitle('filePane.fileMenu.label').length).toBe(4) - }) - - test('Clicking file name opens file tab',() => { - fireEvent.click(screen.queryByText('a.py').parentElement) - expect(openFileTab).toHaveBeenCalledWith('a.py') - }) - - test('it renders with the expected icons', () => { - expect(screen.getByTestId('pythonIcon')).toBeTruthy() - expect(screen.getByTestId('htmlIcon')).toBeTruthy() - expect(screen.getByTestId('cssIcon')).toBeTruthy() - expect(screen.getByTestId('csvIcon')).toBeTruthy() - }) -}) - -describe('it renders the expected icon for individual files', () => { - test('it renders the expected icon for an individual python file', () => { - const store = createMockStore([ { name: "a", extension: "py" } ]) - render(<Provider store={store}><div id="app"><FilesList openFileTab={openFileTab}/></div></Provider>) - - expect(screen.getAllByTitle('filePane.fileMenu.label').length).toBe(1) - expect(screen.getByTestId('pythonIcon')).toBeTruthy() - }) - - test('it renders the expected icon for an individual html file', () => { - const store = createMockStore([ { name: "a", extension: "html" } ]) - render(<Provider store={store}><div id="app"><FilesList openFileTab={openFileTab}/></div></Provider>) - - expect(screen.getAllByTitle('filePane.fileMenu.label').length).toBe(1) - expect(screen.getByTestId('htmlIcon')).toBeTruthy() - }) - - test('it renders the expected icon for an individual css file', () => { - const store = createMockStore([ { name: "a", extension: "css" } ]) - render(<Provider store={store}><div id="app"><FilesList openFileTab={openFileTab}/></div></Provider>) - - expect(screen.getAllByTitle('filePane.fileMenu.label').length).toBe(1) - expect(screen.getByTestId('cssIcon')).toBeTruthy() - }) - - test('it renders the expected icon for an individual csv file', () => { - const store = createMockStore([ { name: "a", extension: "csv" } ]) - render(<Provider store={store}><div id="app"><FilesList openFileTab={openFileTab}/></div></Provider>) - - expect(screen.getAllByTitle('filePane.fileMenu.label').length).toBe(1) - expect(screen.getByTestId('csvIcon')).toBeTruthy() - }) - - test('it renders the expected icon for any other file type', () => { - const store = createMockStore([ { name: "a", extension: "docx" } ]) - render(<Provider store={store}><div id="app"><FilesList openFileTab={openFileTab}/></div></Provider>) - - expect(screen.getAllByTitle('filePane.fileMenu.label').length).toBe(1) - expect(screen.getByTestId('defaultFileIcon')).toBeTruthy() - }) -}) + expect(screen.getAllByTitle("filePane.fileMenu.label").length).toBe(4); + }); + + test("Clicking file name opens file tab", () => { + fireEvent.click(screen.queryByText("a.py").parentElement); + expect(openFileTab).toHaveBeenCalledWith("a.py"); + }); + test("it renders with the expected icons", () => { + expect(screen.getByTestId("pythonIcon")).toBeTruthy(); + expect(screen.getByTestId("htmlIcon")).toBeTruthy(); + expect(screen.getByTestId("cssIcon")).toBeTruthy(); + expect(screen.getByTestId("csvIcon")).toBeTruthy(); + }); +}); + +describe("it renders the expected icon for individual files", () => { + test("it renders the expected icon for an individual python file", () => { + const store = createMockStore([{ name: "a", extension: "py" }]); + render( + <Provider store={store}> + <div id="app"> + <FilesList openFileTab={openFileTab} /> + </div> + </Provider>, + ); + + expect(screen.getAllByTitle("filePane.fileMenu.label").length).toBe(1); + expect(screen.getByTestId("pythonIcon")).toBeTruthy(); + }); + + test("it renders the expected icon for an individual html file", () => { + const store = createMockStore([{ name: "a", extension: "html" }]); + render( + <Provider store={store}> + <div id="app"> + <FilesList openFileTab={openFileTab} /> + </div> + </Provider>, + ); + + expect(screen.getAllByTitle("filePane.fileMenu.label").length).toBe(1); + expect(screen.getByTestId("htmlIcon")).toBeTruthy(); + }); + + test("it renders the expected icon for an individual css file", () => { + const store = createMockStore([{ name: "a", extension: "css" }]); + render( + <Provider store={store}> + <div id="app"> + <FilesList openFileTab={openFileTab} /> + </div> + </Provider>, + ); + + expect(screen.getAllByTitle("filePane.fileMenu.label").length).toBe(1); + expect(screen.getByTestId("cssIcon")).toBeTruthy(); + }); + + test("it renders the expected icon for an individual csv file", () => { + const store = createMockStore([{ name: "a", extension: "csv" }]); + render( + <Provider store={store}> + <div id="app"> + <FilesList openFileTab={openFileTab} /> + </div> + </Provider>, + ); + + expect(screen.getAllByTitle("filePane.fileMenu.label").length).toBe(1); + expect(screen.getByTestId("csvIcon")).toBeTruthy(); + }); + + test("it renders the expected icon for any other file type", () => { + const store = createMockStore([{ name: "a", extension: "docx" }]); + render( + <Provider store={store}> + <div id="app"> + <FilesList openFileTab={openFileTab} /> + </div> + </Provider>, + ); + + expect(screen.getAllByTitle("filePane.fileMenu.label").length).toBe(1); + expect(screen.getByTestId("defaultFileIcon")).toBeTruthy(); + }); +}); diff --git a/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.js b/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.js index e0eb87ef1..a0ba1493a 100644 --- a/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.js +++ b/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.js @@ -1,33 +1,37 @@ -import './ProjectImages.scss'; +import "./ProjectImages.scss"; -import { useSelector } from 'react-redux' -import { ChevronDown } from '../../../../../Icons'; -import { useTranslation } from 'react-i18next' +import { useSelector } from "react-redux"; +import { ChevronDown } from "../../../../../Icons"; +import { useTranslation } from "react-i18next"; const ProjectImages = () => { const projectImages = useSelector((state) => state.editor.project.image_list); - const { t } = useTranslation() + const { t } = useTranslation(); return ( - <details className='file-pane-section file-pane-section__images' open> + <details className="file-pane-section file-pane-section__images" open> <summary> - <h2 className='menu-pop-out-subheading'>{t('filePane.images')}</h2> - <div className='accordion-icon'> + <h2 className="menu-pop-out-subheading">{t("filePane.images")}</h2> + <div className="accordion-icon"> <ChevronDown /> </div> </summary> - <div className='project-images'> + <div className="project-images"> {projectImages.map((image, i) => ( - <div key={i} className='project-images__block'> - <div className='project-images__image-wrapper'> - <img className='project-images__image' src={image.url} alt={image.filename}/> + <div key={i} className="project-images__block"> + <div className="project-images__image-wrapper"> + <img + className="project-images__image" + src={image.url} + alt={image.filename} + /> </div> <p>{image.filename}</p> </div> ))} </div> </details> - ) -} + ); +}; -export default ProjectImages +export default ProjectImages; diff --git a/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.test.js b/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.test.js index 16f2a71f2..2c401318d 100644 --- a/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.test.js +++ b/src/components/Menus/SideMenu/FilePane/ProjectImages/ProjectImages.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import ProjectImages from "./ProjectImages"; @@ -10,46 +10,50 @@ describe("Project with images", () => { let queryByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { name: "main", - extension: "py" - } + extension: "py", + }, ], image_list: [ { filename: "image1.jpg", - url: "image1_url" + url: "image1_url", }, { filename: "image2.jpg", - url: "image2_url" + url: "image2_url", }, - ] - } + ], + }, }, - } + }; const store = mockStore(initialState); - ({ queryByAltText, queryByText } = render(<Provider store={store}><ProjectImages /></Provider>)) - }) + ({ queryByAltText, queryByText } = render( + <Provider store={store}> + <ProjectImages /> + </Provider>, + )); + }); test("Image names are rendered", () => { - expect(queryByText("image1.jpg")).not.toBeNull() - expect(queryByText("image2.jpg")).not.toBeNull() - }) + expect(queryByText("image1.jpg")).not.toBeNull(); + expect(queryByText("image2.jpg")).not.toBeNull(); + }); test("Images are rendered", () => { - expect(queryByAltText("image1.jpg")).not.toBeNull() - expect(queryByAltText("image2.jpg")).not.toBeNull() - }) + expect(queryByAltText("image1.jpg")).not.toBeNull(); + expect(queryByAltText("image2.jpg")).not.toBeNull(); + }); test("Images have the expected source", () => { - expect(queryByAltText("image1.jpg")).toHaveAttribute('src', 'image1_url') - expect(queryByAltText("image2.jpg")).toHaveAttribute('src', 'image2_url') - }) -}) + expect(queryByAltText("image1.jpg")).toHaveAttribute("src", "image1_url"); + expect(queryByAltText("image2.jpg")).toHaveAttribute("src", "image2_url"); + }); +}); diff --git a/src/components/Menus/SideMenu/MenuSideBar.js b/src/components/Menus/SideMenu/MenuSideBar.js index 5bf0a65f6..7254f27b0 100644 --- a/src/components/Menus/SideMenu/MenuSideBar.js +++ b/src/components/Menus/SideMenu/MenuSideBar.js @@ -1,36 +1,60 @@ -import React from "react" -import { useTranslation } from "react-i18next" -import { DoubleChevronRight } from "../../../Icons" -import Button from "../../Button/Button" -import MenuSideBarOption from "./MenuSideBarOption" - +import React from "react"; +import { useTranslation } from "react-i18next"; +import { DoubleChevronRight } from "../../../Icons"; +import Button from "../../Button/Button"; +import MenuSideBarOption from "./MenuSideBarOption"; const MenuSideBar = (props) => { - const {menuOptions, option, toggleOption} = props - const { t } = useTranslation() - const topMenuOptions = menuOptions.filter((menuOption => menuOption.position === "top")) - const bottomMenuOptions = menuOptions.filter((menuOption => menuOption.position === "bottom")) + const { menuOptions, option, toggleOption } = props; + const { t } = useTranslation(); + const topMenuOptions = menuOptions.filter( + (menuOption) => menuOption.position === "top", + ); + const bottomMenuOptions = menuOptions.filter( + (menuOption) => menuOption.position === "bottom", + ); const expandPopOut = () => { - toggleOption('file') - window.plausible('Expand file pane') - } + toggleOption("file"); + window.plausible("Expand file pane"); + }; return ( <div className="menu-sidebar"> <div className={`menu-options-top`}> {topMenuOptions.map((menuOption, i) => ( - <MenuSideBarOption key={i} Icon={menuOption.icon} title={menuOption.title} isActive={option === menuOption.name} toggleOption={toggleOption} name={menuOption.name}/> - ))} + <MenuSideBarOption + key={i} + Icon={menuOption.icon} + title={menuOption.title} + isActive={option === menuOption.name} + toggleOption={toggleOption} + name={menuOption.name} + /> + ))} </div> <div className={`menu-options-bottom`}> {bottomMenuOptions.map((menuOption, i) => ( - <MenuSideBarOption key={i} Icon={menuOption.icon} title={menuOption.title} isActive={option === menuOption.name} toggleOption={toggleOption} name={menuOption.name}/> - ))} - <Button className='btn--secondary btn--small' ButtonIcon={DoubleChevronRight} title={t('sideMenu.expand')} buttonOuter buttonOuterClassName = 'menu-expand-button' onClickHandler={expandPopOut}/> + <MenuSideBarOption + key={i} + Icon={menuOption.icon} + title={menuOption.title} + isActive={option === menuOption.name} + toggleOption={toggleOption} + name={menuOption.name} + /> + ))} + <Button + className="btn--secondary btn--small" + ButtonIcon={DoubleChevronRight} + title={t("sideMenu.expand")} + buttonOuter + buttonOuterClassName="menu-expand-button" + onClickHandler={expandPopOut} + /> </div> </div> - ) -} + ); +}; -export default MenuSideBar +export default MenuSideBar; diff --git a/src/components/Menus/SideMenu/MenuSideBar.test.js b/src/components/Menus/SideMenu/MenuSideBar.test.js index fe9c01b90..106c13ec7 100644 --- a/src/components/Menus/SideMenu/MenuSideBar.test.js +++ b/src/components/Menus/SideMenu/MenuSideBar.test.js @@ -2,19 +2,19 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import MenuSideBar from "./MenuSideBar"; -const toggleOption = jest.fn() +const toggleOption = jest.fn(); beforeEach(() => { render( - <MenuSideBar - menuOptions = {[{name: 'file', title: 'my_title', popOut: () => {}}]} - toggleOption = {toggleOption} - />) -}) - -test('Clicking expand button opens file pane', () => { - const expandButton = screen.getByTitle('sideMenu.expand') - fireEvent.click(expandButton) - expect(toggleOption).toHaveBeenCalledWith('file') -}) + <MenuSideBar + menuOptions={[{ name: "file", title: "my_title", popOut: () => {} }]} + toggleOption={toggleOption} + />, + ); +}); +test("Clicking expand button opens file pane", () => { + const expandButton = screen.getByTitle("sideMenu.expand"); + fireEvent.click(expandButton); + expect(toggleOption).toHaveBeenCalledWith("file"); +}); diff --git a/src/components/Menus/SideMenu/MenuSideBarOption.js b/src/components/Menus/SideMenu/MenuSideBarOption.js index 7596386f5..6bf5f59fc 100644 --- a/src/components/Menus/SideMenu/MenuSideBarOption.js +++ b/src/components/Menus/SideMenu/MenuSideBarOption.js @@ -1,24 +1,26 @@ -import React from "react" -import Button from "../../Button/Button" +import React from "react"; +import Button from "../../Button/Button"; const MenuSideBarOption = (props) => { - const { Icon, isActive, name, title, toggleOption } = props + const { Icon, isActive, name, title, toggleOption } = props; const onClickHandler = () => { - toggleOption(name) - if (name === 'file') { - window.plausible('Side menu open project files') + toggleOption(name); + if (name === "file") { + window.plausible("Side menu open project files"); } - } + }; return ( <Button - className = {`btn--tertiary menu-sidebar-option${isActive ? " menu-sidebar-option--active" : ""}`} - ButtonIcon = {Icon} - title = {title} - onClickHandler = {onClickHandler} + className={`btn--tertiary menu-sidebar-option${ + isActive ? " menu-sidebar-option--active" : "" + }`} + ButtonIcon={Icon} + title={title} + onClickHandler={onClickHandler} /> - ) -} + ); +}; -export default MenuSideBarOption +export default MenuSideBarOption; diff --git a/src/components/Menus/SideMenu/MenuSidebarOption.test.js b/src/components/Menus/SideMenu/MenuSidebarOption.test.js index 759a5f9c1..de01e5af7 100644 --- a/src/components/Menus/SideMenu/MenuSidebarOption.test.js +++ b/src/components/Menus/SideMenu/MenuSidebarOption.test.js @@ -2,20 +2,20 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import MenuSideBarOption from "./MenuSideBarOption"; -const toggleOption = jest.fn() +const toggleOption = jest.fn(); beforeEach(() => { render( <MenuSideBarOption - name = 'file' - title = 'my_title' - toggleOption = {toggleOption} - /> - ) -}) + name="file" + title="my_title" + toggleOption={toggleOption} + />, + ); +}); -test('Clicking expand button with correct title opens file pane', () => { - const optionButton = screen.getByTitle('my_title') - fireEvent.click(optionButton) - expect(toggleOption).toHaveBeenCalledWith('file') -}) +test("Clicking expand button with correct title opens file pane", () => { + const optionButton = screen.getByTitle("my_title"); + fireEvent.click(optionButton); + expect(toggleOption).toHaveBeenCalledWith("file"); +}); diff --git a/src/components/Menus/SideMenu/SideMenu.js b/src/components/Menus/SideMenu/SideMenu.js index 270182214..00c797051 100644 --- a/src/components/Menus/SideMenu/SideMenu.js +++ b/src/components/Menus/SideMenu/SideMenu.js @@ -1,45 +1,62 @@ -import React, { useState } from "react" -import { useTranslation } from "react-i18next" -import { DoubleChevronLeft, FileIcon } from "../../../Icons" -import Button from "../../Button/Button" -import FilePane from "./FilePane/FilePane" -import MenuSideBar from "./MenuSideBar" +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { DoubleChevronLeft, FileIcon } from "../../../Icons"; +import Button from "../../Button/Button"; +import FilePane from "./FilePane/FilePane"; +import MenuSideBar from "./MenuSideBar"; -import './SideMenu.scss' +import "./SideMenu.scss"; const SideMenu = (props) => { - const { openFileTab } = props - const { t } = useTranslation() + const { openFileTab } = props; + const { t } = useTranslation(); const menuOptions = [ - { name: "file", icon: FileIcon, title: t('sideMenu.file'), position: "top", popOut: () => FilePane({ openFileTab: openFileTab }) } - ] - const [option, setOption] = useState('file') + { + name: "file", + icon: FileIcon, + title: t("sideMenu.file"), + position: "top", + popOut: () => FilePane({ openFileTab: openFileTab }), + }, + ]; + const [option, setOption] = useState("file"); const toggleOption = (newOption) => { - option !== newOption ? setOption(newOption) : setOption(null) - } + option !== newOption ? setOption(newOption) : setOption(null); + }; const optionDict = menuOptions.find((menuOption) => { - return menuOption.name === option - }) - const MenuPopOut = optionDict && optionDict.popOut ? optionDict.popOut : () => {} + return menuOption.name === option; + }); + const MenuPopOut = + optionDict && optionDict.popOut ? optionDict.popOut : () => {}; const collapsePopOut = () => { - toggleOption(option) - window.plausible('Collapse file pane') - } + toggleOption(option); + window.plausible("Collapse file pane"); + }; return ( - <div className='menu'> - { option ? null : - <MenuSideBar menuOptions={menuOptions} option={option} toggleOption = {toggleOption}/> - } + <div className="menu"> + {option ? null : ( + <MenuSideBar + menuOptions={menuOptions} + option={option} + toggleOption={toggleOption} + /> + )} <MenuPopOut /> - {option ? - <Button className='btn--secondary btn--small' ButtonIcon={DoubleChevronLeft} buttonOuter buttonOuterClassName = 'menu-collapse-button' title={t('sideMenu.collapse')} onClickHandler={collapsePopOut} /> - : null - } + {option ? ( + <Button + className="btn--secondary btn--small" + ButtonIcon={DoubleChevronLeft} + buttonOuter + buttonOuterClassName="menu-collapse-button" + title={t("sideMenu.collapse")} + onClickHandler={collapsePopOut} + /> + ) : null} </div> - ) -} + ); +}; -export default SideMenu +export default SideMenu; diff --git a/src/components/Menus/SideMenu/SideMenu.test.js b/src/components/Menus/SideMenu/SideMenu.test.js index bbb643636..530c8c757 100644 --- a/src/components/Menus/SideMenu/SideMenu.test.js +++ b/src/components/Menus/SideMenu/SideMenu.test.js @@ -1,28 +1,34 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import SideMenu from "./SideMenu"; -import configureStore from 'redux-mock-store'; +import configureStore from "redux-mock-store"; import { Provider } from "react-redux"; beforeEach(() => { - const mockStore = configureStore([]) - const initialState = { - editor: { - project: { - components: [] - }, + const mockStore = configureStore([]); + const initialState = { + editor: { + project: { + components: [], }, - } - const store = mockStore(initialState); - render(<Provider store={store}><div id="app"><SideMenu/></div></Provider>) -}) + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <div id="app"> + <SideMenu /> + </div> + </Provider>, + ); +}); -test('File pane open by default', () => { - expect(screen.getByRole('heading')).toHaveTextContent('filePane.files') -}) +test("File pane open by default", () => { + expect(screen.getByRole("heading")).toHaveTextContent("filePane.files"); +}); -test('Clicking collapse closes the file pane', () => { - const collapseButton = screen.getByTitle('sideMenu.collapse') - fireEvent.click(collapseButton) - expect(screen.queryByText('filePane.files')).not.toBeInTheDocument() -}) +test("Clicking collapse closes the file pane", () => { + const collapseButton = screen.getByTitle("sideMenu.collapse"); + fireEvent.click(collapseButton); + expect(screen.queryByText("filePane.files")).not.toBeInTheDocument(); +}); diff --git a/src/components/Modals/AccessDeniedNoAuthModal.js b/src/components/Modals/AccessDeniedNoAuthModal.js index 9b0e0aa7f..70c1ffd3d 100644 --- a/src/components/Modals/AccessDeniedNoAuthModal.js +++ b/src/components/Modals/AccessDeniedNoAuthModal.js @@ -3,41 +3,60 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import Button from "../Button/Button"; -import '../../Modal.scss'; +import "../../Modal.scss"; import { closeAccessDeniedNoAuthModal } from "../Editor/EditorSlice"; import LoginButton from "../Login/LoginButton"; import GeneralModal from "./GeneralModal"; import { login } from "../../utils/login"; const AccessDeniedNoAuthModal = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { t } = useTranslation(); - - const isModalOpen = useSelector((state) => state.editor.accessDeniedNoAuthModalShowing) - const accessDeniedData = useSelector((state) => state.editor.modals.accessDenied) + + const isModalOpen = useSelector( + (state) => state.editor.accessDeniedNoAuthModalShowing, + ); + const accessDeniedData = useSelector( + (state) => state.editor.modals.accessDenied, + ); const closeModal = () => dispatch(closeAccessDeniedNoAuthModal()); const defaultCallback = () => { - login({accessDeniedData}) - } + login({ accessDeniedData }); + }; return ( <GeneralModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('project.accessDeniedNoAuthModal.heading')} + heading={t("project.accessDeniedNoAuthModal.heading")} text={[ - {type: 'paragraph', content: t('project.accessDeniedNoAuthModal.text')} + { + type: "paragraph", + content: t("project.accessDeniedNoAuthModal.text"), + }, ]} buttons={[ - <LoginButton buttonText={t('project.accessDeniedNoAuthModal.loginButtonText')} className = 'btn--primary'/>, - <a className='btn btn--secondary' href='https://projects.raspberrypi.org'>{t('project.accessDeniedNoAuthModal.projectsSiteLinkText')}</a>, - <Button buttonText = {t('project.accessDeniedNoAuthModal.newProject')} className='btn--tertiary' onClickHandler={closeModal}/> + <LoginButton + buttonText={t("project.accessDeniedNoAuthModal.loginButtonText")} + className="btn--primary" + />, + <a + className="btn btn--secondary" + href="https://projects.raspberrypi.org" + > + {t("project.accessDeniedNoAuthModal.projectsSiteLinkText")} + </a>, + <Button + buttonText={t("project.accessDeniedNoAuthModal.newProject")} + className="btn--tertiary" + onClickHandler={closeModal} + />, ]} defaultCallback={defaultCallback} /> ); -} +}; export default AccessDeniedNoAuthModal; diff --git a/src/components/Modals/AccessDeniedNoAuthModal.test.js b/src/components/Modals/AccessDeniedNoAuthModal.test.js index c829780b8..fa09dfb45 100644 --- a/src/components/Modals/AccessDeniedNoAuthModal.test.js +++ b/src/components/Modals/AccessDeniedNoAuthModal.test.js @@ -1,18 +1,18 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import AccessDeniedNoAuthModal from "./AccessDeniedNoAuthModal"; -jest.mock('react-router-dom', () => ({ - useLocation: jest.fn() -})) +jest.mock("react-router-dom", () => ({ + useLocation: jest.fn(), +})); -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); -describe('When accessDeniedNoAuthModalShowing is true', () => { - let store +describe("When accessDeniedNoAuthModalShowing is true", () => { + let store; beforeEach(() => { const initialState = { @@ -20,23 +20,35 @@ describe('When accessDeniedNoAuthModalShowing is true', () => { accessDeniedNoAuthModalShowing: true, modals: { accessDenied: { - identifer: 'my-amazing-project', - projectType: 'python' - } - } - } - } + identifer: "my-amazing-project", + projectType: "python", + }, + }, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><div id='app'><AccessDeniedNoAuthModal /></div></Provider>) - }) + render( + <Provider store={store}> + <div id="app"> + <AccessDeniedNoAuthModal /> + </div> + </Provider>, + ); + }); - test('Modal rendered', () => { - expect(screen.queryByText('project.accessDeniedNoAuthModal.heading')).toBeInTheDocument() - }) + test("Modal rendered", () => { + expect( + screen.queryByText("project.accessDeniedNoAuthModal.heading"), + ).toBeInTheDocument(); + }); - test('Clicking new project dispatches close modal action', () => { - const newProjectLink = screen.queryByText('project.accessDeniedNoAuthModal.newProject') - fireEvent.click(newProjectLink) - expect(store.getActions()).toEqual([{type: 'editor/closeAccessDeniedNoAuthModal'}]) - }) -}) + test("Clicking new project dispatches close modal action", () => { + const newProjectLink = screen.queryByText( + "project.accessDeniedNoAuthModal.newProject", + ); + fireEvent.click(newProjectLink); + expect(store.getActions()).toEqual([ + { type: "editor/closeAccessDeniedNoAuthModal" }, + ]); + }); +}); diff --git a/src/components/Modals/AccessDeniedWithAuthModal.js b/src/components/Modals/AccessDeniedWithAuthModal.js index 2a04ae246..e77b808c5 100644 --- a/src/components/Modals/AccessDeniedWithAuthModal.js +++ b/src/components/Modals/AccessDeniedWithAuthModal.js @@ -3,39 +3,62 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import Button from "../Button/Button"; -import '../../Modal.scss'; -import { closeAccessDeniedWithAuthModal, syncProject } from "../Editor/EditorSlice"; +import "../../Modal.scss"; +import { + closeAccessDeniedWithAuthModal, + syncProject, +} from "../Editor/EditorSlice"; import { defaultPythonProject } from "../../utils/defaultProjects"; import GeneralModal from "./GeneralModal"; const AccessDeniedWithAuthModal = () => { - const dispatch = useDispatch() - const { t } = useTranslation() - const user = useSelector((state) => state.auth.user) - - const isModalOpen = useSelector((state) => state.editor.accessDeniedWithAuthModalShowing) + const dispatch = useDispatch(); + const { t } = useTranslation(); + const user = useSelector((state) => state.auth.user); + + const isModalOpen = useSelector( + (state) => state.editor.accessDeniedWithAuthModalShowing, + ); const closeModal = () => dispatch(closeAccessDeniedWithAuthModal()); const createNewProject = async () => { - dispatch(syncProject('save')({ project: defaultPythonProject, accessToken: user.access_token, autosave: false })) - } + dispatch( + syncProject("save")({ + project: defaultPythonProject, + accessToken: user.access_token, + autosave: false, + }), + ); + }; return ( <GeneralModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('project.accessDeniedWithAuthModal.heading')} + heading={t("project.accessDeniedWithAuthModal.heading")} text={[ - {type: 'paragraph', content: t('project.accessDeniedWithAuthModal.text')} + { + type: "paragraph", + content: t("project.accessDeniedWithAuthModal.text"), + }, ]} buttons={[ - <Button className='btn--primary' buttonText={t('project.accessDeniedWithAuthModal.newProject')} onClickHandler={createNewProject} />, - <a className='btn btn--secondary' href='https://projects.raspberrypi.org'>{t('project.accessDeniedWithAuthModal.projectsSiteLinkText')}</a> + <Button + className="btn--primary" + buttonText={t("project.accessDeniedWithAuthModal.newProject")} + onClickHandler={createNewProject} + />, + <a + className="btn btn--secondary" + href="https://projects.raspberrypi.org" + > + {t("project.accessDeniedWithAuthModal.projectsSiteLinkText")} + </a>, ]} defaultCallback={createNewProject} /> ); -} +}; export default AccessDeniedWithAuthModal; diff --git a/src/components/Modals/AccessDeniedWithAuthModal.test.js b/src/components/Modals/AccessDeniedWithAuthModal.test.js index 5c0437a7a..227f64e47 100644 --- a/src/components/Modals/AccessDeniedWithAuthModal.test.js +++ b/src/components/Modals/AccessDeniedWithAuthModal.test.js @@ -1,28 +1,28 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import AccessDeniedWithAuthModal from "./AccessDeniedWithAuthModal"; import { syncProject } from "../Editor/EditorSlice"; import { defaultPythonProject } from "../../utils/defaultProjects"; -jest.mock('../Editor/EditorSlice', () => ({ - ...jest.requireActual('../Editor/EditorSlice'), - syncProject: jest.fn((_) => jest.fn()) -})) +jest.mock("../Editor/EditorSlice", () => ({ + ...jest.requireActual("../Editor/EditorSlice"), + syncProject: jest.fn((_) => jest.fn()), +})); const user = { access_token: "39a09671-be55-4847-baf5-8919a0c24a25", profile: { - user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" - } -} + user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", + }, +}; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); -describe('When accessDeniedWithAuthModalShowing is true', () => { - let store +describe("When accessDeniedWithAuthModalShowing is true", () => { + let store; beforeEach(() => { const initialState = { @@ -30,28 +30,40 @@ describe('When accessDeniedWithAuthModalShowing is true', () => { accessDeniedWithAuthModalShowing: true, }, auth: { - user: user - } - } + user: user, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><div id='app'><AccessDeniedWithAuthModal /></div></Provider>) - }) + render( + <Provider store={store}> + <div id="app"> + <AccessDeniedWithAuthModal /> + </div> + </Provider>, + ); + }); - test('Modal rendered', () => { - expect(screen.queryByText('project.accessDeniedWithAuthModal.heading')).toBeInTheDocument() - }) + test("Modal rendered", () => { + expect( + screen.queryByText("project.accessDeniedWithAuthModal.heading"), + ).toBeInTheDocument(); + }); - test('Clicking new project creates a new project', async () => { - const newProjectLink = screen.queryByText('project.accessDeniedWithAuthModal.newProject') - const saveAction = {type: 'SAVE_PROJECT' } - const saveProject = jest.fn(() => saveAction) - syncProject.mockImplementationOnce(jest.fn((_) => (saveProject))) - fireEvent.click(newProjectLink) - await waitFor(() => expect(saveProject).toHaveBeenCalledWith({ - project: defaultPythonProject, - accessToken: user.access_token, - autosave: false - })) - expect(store.getActions()[0]).toEqual(saveAction) - }) -}) + test("Clicking new project creates a new project", async () => { + const newProjectLink = screen.queryByText( + "project.accessDeniedWithAuthModal.newProject", + ); + const saveAction = { type: "SAVE_PROJECT" }; + const saveProject = jest.fn(() => saveAction); + syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + fireEvent.click(newProjectLink); + await waitFor(() => + expect(saveProject).toHaveBeenCalledWith({ + project: defaultPythonProject, + accessToken: user.access_token, + autosave: false, + }), + ); + expect(store.getActions()[0]).toEqual(saveAction); + }); +}); diff --git a/src/components/Modals/BetaModal.js b/src/components/Modals/BetaModal.js index 2b0d2ecd9..1b8035f18 100644 --- a/src/components/Modals/BetaModal.js +++ b/src/components/Modals/BetaModal.js @@ -5,32 +5,36 @@ import { useTranslation } from "react-i18next"; import Button from "../Button/Button"; import { closeBetaModal } from "../Editor/EditorSlice"; import GeneralModal from "./GeneralModal"; -import '../../Modal.scss'; +import "../../Modal.scss"; const BetaModal = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { t } = useTranslation(); - - const isModalOpen = useSelector((state) => state.editor.betaModalShowing) + + const isModalOpen = useSelector((state) => state.editor.betaModalShowing); const closeModal = () => dispatch(closeBetaModal()); return ( <GeneralModal isOpen={isModalOpen} closeModal={closeModal} - heading={t('betaBanner.modal.heading')} + heading={t("betaBanner.modal.heading")} text={[ - {type: 'subheading', content: t('betaBanner.modal.meaningHeading')}, - {type: 'paragraph', content: t('betaBanner.modal.meaningText')}, - {type: 'subheading', content: t('betaBanner.modal.whatNextHeading')}, - {type: 'paragraph', content: t('betaBanner.modal.whatNextText')} + { type: "subheading", content: t("betaBanner.modal.meaningHeading") }, + { type: "paragraph", content: t("betaBanner.modal.meaningText") }, + { type: "subheading", content: t("betaBanner.modal.whatNextHeading") }, + { type: "paragraph", content: t("betaBanner.modal.whatNextText") }, ]} buttons={[ - <Button className='btn--primary' buttonText={t('betaBanner.modal.close')} onClickHandler={closeModal} /> + <Button + className="btn--primary" + buttonText={t("betaBanner.modal.close")} + onClickHandler={closeModal} + />, ]} defaultCallback={closeModal} /> ); -} +}; export default BetaModal; diff --git a/src/components/Modals/BetaModal.test.js b/src/components/Modals/BetaModal.test.js index 3148a8486..e69ecbc17 100644 --- a/src/components/Modals/BetaModal.test.js +++ b/src/components/Modals/BetaModal.test.js @@ -1,36 +1,48 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import BetaModal from "./BetaModal"; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); -test('Modal rendered when betaModalShowing is true', () => { +test("Modal rendered when betaModalShowing is true", () => { const initialState = { editor: { - betaModalShowing: true - } - } + betaModalShowing: true, + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><div id='app'><BetaModal /></div></Provider>) + render( + <Provider store={store}> + <div id="app"> + <BetaModal /> + </div> + </Provider>, + ); - expect(screen.queryByText('betaBanner.modal.heading')).toBeInTheDocument() -}) + expect(screen.queryByText("betaBanner.modal.heading")).toBeInTheDocument(); +}); -test('Clicking close dispatches close modal action', () => { +test("Clicking close dispatches close modal action", () => { const initialState = { editor: { - betaModalShowing: true - } - } + betaModalShowing: true, + }, + }; const store = mockStore(initialState); - render(<Provider store={store}><div id='app'><BetaModal /></div></Provider>) + render( + <Provider store={store}> + <div id="app"> + <BetaModal /> + </div> + </Provider>, + ); - const closeButton = screen.queryByText('betaBanner.modal.close') - fireEvent.click(closeButton) - expect(store.getActions()).toEqual([{type: 'editor/closeBetaModal'}]) -}) + const closeButton = screen.queryByText("betaBanner.modal.close"); + fireEvent.click(closeButton); + expect(store.getActions()).toEqual([{ type: "editor/closeBetaModal" }]); +}); diff --git a/src/components/Modals/DeleteProjectModal.js b/src/components/Modals/DeleteProjectModal.js index f88c2a48f..70f2f0593 100644 --- a/src/components/Modals/DeleteProjectModal.js +++ b/src/components/Modals/DeleteProjectModal.js @@ -1,5 +1,5 @@ import React from "react"; -import { gql, useMutation } from '@apollo/client'; +import { gql, useMutation } from "@apollo/client"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { closeDeleteProjectModal } from "../Editor/EditorSlice"; @@ -9,44 +9,62 @@ import GeneralModal from "./GeneralModal"; // Define mutation export const DELETE_PROJECT_MUTATION = gql` mutation DeleteProject($id: String!) { - deleteProject(input: {id: $id}) { + deleteProject(input: { id: $id }) { id } } `; export const DeleteProjectModal = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { t } = useTranslation(); - const isModalOpen = useSelector((state) => state.editor.deleteProjectModalShowing) - const project = useSelector((state) => state.editor.modals.deleteProject) + const isModalOpen = useSelector( + (state) => state.editor.deleteProjectModalShowing, + ); + const project = useSelector((state) => state.editor.modals.deleteProject); const closeModal = () => dispatch(closeDeleteProjectModal()); // This can capture data, error, loading as per normal queries, but we're not // using them yet. - const [deleteProjectMutation] = useMutation(DELETE_PROJECT_MUTATION, {refetchQueries: ["ProjectIndexQuery"]}) + const [deleteProjectMutation] = useMutation(DELETE_PROJECT_MUTATION, { + refetchQueries: ["ProjectIndexQuery"], + }); const onClickDelete = async () => { - deleteProjectMutation({variables: {id: project.id}, onCompleted: closeModal}) - } + deleteProjectMutation({ + variables: { id: project.id }, + onCompleted: closeModal, + }); + }; return ( <GeneralModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('projectList.deleteProjectModal.heading')} + heading={t("projectList.deleteProjectModal.heading")} text={[ - {type: 'paragraph', content: t('projectList.deleteProjectModal.text')} + { + type: "paragraph", + content: t("projectList.deleteProjectModal.text"), + }, ]} buttons={[ - <Button className='btn--danger' buttonText={t('projectList.deleteProjectModal.delete')} onClickHandler={onClickDelete} />, - <Button className='btn--secondary' buttonText={t('projectList.deleteProjectModal.cancel')} onClickHandler={closeModal} /> + <Button + className="btn--danger" + buttonText={t("projectList.deleteProjectModal.delete")} + onClickHandler={onClickDelete} + />, + <Button + className="btn--secondary" + buttonText={t("projectList.deleteProjectModal.cancel")} + onClickHandler={closeModal} + />, ]} defaultCallback={onClickDelete} /> ); -} +}; -export default DeleteProjectModal +export default DeleteProjectModal; diff --git a/src/components/Modals/DeleteProjectModal.test.js b/src/components/Modals/DeleteProjectModal.test.js index 9ddf7d971..62889cc5a 100644 --- a/src/components/Modals/DeleteProjectModal.test.js +++ b/src/components/Modals/DeleteProjectModal.test.js @@ -1,77 +1,94 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import { MockedProvider } from "@apollo/client/testing"; -import { DeleteProjectModal, DELETE_PROJECT_MUTATION } from "./DeleteProjectModal"; +import { + DeleteProjectModal, + DELETE_PROJECT_MUTATION, +} from "./DeleteProjectModal"; describe("Testing the delete project modal", () => { let store; let mocks; - let project = { id: 'abc', name: 'my first project' } + let project = { id: "abc", name: "my first project" }; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { modals: { - deleteProject: project + deleteProject: project, }, - deleteProjectModalShowing: true - } - } + deleteProjectModalShowing: true, + }, + }; mocks = [ { request: { query: DELETE_PROJECT_MUTATION, - variables: { id: project.id } + variables: { id: project.id }, }, - result: jest.fn(() => ({ + result: jest.fn(() => ({ data: { deleteProject: { - id: project.id - } - } - })) - } - ] + id: project.id, + }, + }, + })), + }, + ]; store = mockStore(initialState); render( <MockedProvider mocks={mocks}> <Provider store={store}> - <div id='app'> + <div id="app"> <DeleteProjectModal /> </div> </Provider> - </MockedProvider> - ) - }) + </MockedProvider>, + ); + }); - test('Modal renders', () => { - expect(screen.queryByText('projectList.deleteProjectModal.heading')).toBeInTheDocument() - }) + test("Modal renders", () => { + expect( + screen.queryByText("projectList.deleteProjectModal.heading"), + ).toBeInTheDocument(); + }); - test('Clicking cancel button closes modal and does not save', () => { - const cancelButton = screen.queryByText('projectList.deleteProjectModal.cancel') - fireEvent.click(cancelButton) - expect(store.getActions()).toEqual([{type: 'editor/closeDeleteProjectModal'}]) - }) + test("Clicking cancel button closes modal and does not save", () => { + const cancelButton = screen.queryByText( + "projectList.deleteProjectModal.cancel", + ); + fireEvent.click(cancelButton); + expect(store.getActions()).toEqual([ + { type: "editor/closeDeleteProjectModal" }, + ]); + }); test("Clicking delete button (eventually) closes the modal", async () => { - const deleteButton = screen.getByText('projectList.deleteProjectModal.delete') - fireEvent.click(deleteButton) - await waitFor(() => expect(store.getActions()).toEqual([{type: 'editor/closeDeleteProjectModal'}])) - }) + const deleteButton = screen.getByText( + "projectList.deleteProjectModal.delete", + ); + fireEvent.click(deleteButton); + await waitFor(() => + expect(store.getActions()).toEqual([ + { type: "editor/closeDeleteProjectModal" }, + ]), + ); + }); test("Clicking delete button calls the mutation", async () => { - const deleteButton = screen.getByText('projectList.deleteProjectModal.delete') - const deleteProjectMutationMock = mocks[0].result - fireEvent.click(deleteButton) - await waitFor(() => expect(deleteProjectMutationMock).toHaveBeenCalled()) - }) -}) + const deleteButton = screen.getByText( + "projectList.deleteProjectModal.delete", + ); + const deleteProjectMutationMock = mocks[0].result; + fireEvent.click(deleteButton); + await waitFor(() => expect(deleteProjectMutationMock).toHaveBeenCalled()); + }); +}); diff --git a/src/components/Modals/ErrorModal.test.js b/src/components/Modals/ErrorModal.test.js index 7513fa5c8..2a9df7776 100644 --- a/src/components/Modals/ErrorModal.test.js +++ b/src/components/Modals/ErrorModal.test.js @@ -20,12 +20,12 @@ test("Modal rendered when errorModalShowing is true", () => { <div id="app"> <ErrorModal /> </div> - </Provider> + </Provider>, ); expect(screen.queryByText("modal.error.heading")).toBeInTheDocument(); expect( - screen.queryByText("modal.error.null.message") + screen.queryByText("modal.error.null.message"), ).not.toBeInTheDocument(); }); @@ -42,7 +42,7 @@ test("Modal not rendered when errorModalShowing is false", () => { <div id="app"> <ErrorModal /> </div> - </Provider> + </Provider>, ); expect(screen.queryByText("modal.error.heading")).not.toBeInTheDocument(); @@ -61,7 +61,7 @@ test("Clicking close dispatches close modal action", () => { <div id="app"> <ErrorModal /> </div> - </Provider> + </Provider>, ); const closeButton = screen.queryByText("modal.close"); @@ -82,11 +82,11 @@ test("Error message shown", () => { <div id="app"> <ErrorModal errorType="someTestError" /> </div> - </Provider> + </Provider>, ); expect( - screen.queryByText("modal.error.someTestError.message") + screen.queryByText("modal.error.someTestError.message"), ).toBeInTheDocument(); }); @@ -104,7 +104,7 @@ test("Additional closeModal function fired", () => { <div id="app"> <ErrorModal additionalOnClose={testOnClose} /> </div> - </Provider> + </Provider>, ); const closeButton = screen.queryByText("modal.close"); diff --git a/src/components/Modals/GeneralModal.js b/src/components/Modals/GeneralModal.js index e7795fc95..e376a6efa 100644 --- a/src/components/Modals/GeneralModal.js +++ b/src/components/Modals/GeneralModal.js @@ -1,57 +1,76 @@ import React from "react"; -import Modal from 'react-modal'; +import Modal from "react-modal"; import Button from "../Button/Button"; -import '../../Modal.scss'; +import "../../Modal.scss"; import { CloseIcon } from "../../Icons"; import { useTranslation } from "react-i18next"; -const GeneralModal = ({buttons=[], children, defaultCallback, heading, isOpen, text=[], withCloseButton = false, closeModal }) => { - const { t } = useTranslation() - const buttonComponents = buttons.map((ButtonFromProps) => ( - () => ButtonFromProps - )) +const GeneralModal = ({ + buttons = [], + children, + defaultCallback, + heading, + isOpen, + text = [], + withCloseButton = false, + closeModal, +}) => { + const { t } = useTranslation(); + const buttonComponents = buttons.map( + (ButtonFromProps) => () => ButtonFromProps, + ); const onKeyDown = (e) => { - if (e.key === 'Enter' && defaultCallback) { - defaultCallback() + if (e.key === "Enter" && defaultCallback) { + defaultCallback(); } - } + }; return ( <div onKeyDown={onKeyDown}> <Modal isOpen={isOpen} onRequestClose={closeModal} - className='modal-content' - overlayClassName='modal-overlay' + className="modal-content" + overlayClassName="modal-overlay" contentLabel={heading} - parentSelector={() => document.querySelector('#app')} - appElement={document.getElementById('app') || undefined} + parentSelector={() => document.querySelector("#app")} + appElement={document.getElementById("app") || undefined} > - <div className='modal-content__header'> - <h2 className='modal-content__heading'>{heading}</h2> - { withCloseButton ? - <Button className='btn--tertiary' onClickHandler={closeModal} ButtonIcon = {CloseIcon} label={t('modals.close')} title={t('modals.close')}/> - : null - } + <div className="modal-content__header"> + <h2 className="modal-content__heading">{heading}</h2> + {withCloseButton ? ( + <Button + className="btn--tertiary" + onClickHandler={closeModal} + ButtonIcon={CloseIcon} + label={t("modals.close")} + title={t("modals.close")} + /> + ) : null} </div> - <div className='modal-content__body'> - {text.map((textItem, i) => ( - textItem.type === 'subheading' ? - <h3 className='modal-content__subheading' key={i}>{textItem.content}</h3> - : - <p className='modal-content__text' key={i}>{textItem.content}</p> - ))} + <div className="modal-content__body"> + {text.map((textItem, i) => + textItem.type === "subheading" ? ( + <h3 className="modal-content__subheading" key={i}> + {textItem.content} + </h3> + ) : ( + <p className="modal-content__text" key={i}> + {textItem.content} + </p> + ), + )} {children} </div> - <div className='modal-content__buttons' > + <div className="modal-content__buttons"> {buttonComponents.map((ButtonComponent, i) => ( - <ButtonComponent key={i}/> + <ButtonComponent key={i} /> ))} </div> </Modal> </div> ); -} +}; export default GeneralModal; diff --git a/src/components/Modals/GeneralModal.test.js b/src/components/Modals/GeneralModal.test.js index 36e94fd0a..20c65e8df 100644 --- a/src/components/Modals/GeneralModal.test.js +++ b/src/components/Modals/GeneralModal.test.js @@ -2,42 +2,38 @@ import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import GeneralModal from "./GeneralModal"; -const defaultCallback = jest.fn() -const closeModal = jest.fn() +const defaultCallback = jest.fn(); +const closeModal = jest.fn(); beforeEach(() => { render( - <div id='app'> + <div id="app"> <GeneralModal isOpen={true} closeModal={closeModal} withCloseButton defaultCallback={defaultCallback} - heading='My modal heading' - text={[ - { content: 'Paragraph1', type: 'paragraph' } - ]} - buttons={[ - <button onClick={jest.fn()}>My amazing button</button> - ]} + heading="My modal heading" + text={[{ content: "Paragraph1", type: "paragraph" }]} + buttons={[<button onClick={jest.fn()}>My amazing button</button>]} /> - </div> - ) -}) + </div>, + ); +}); -test('Renders', () => { - expect(screen.queryByText('My modal heading')).toBeInTheDocument() - expect(screen.queryByText('Paragraph1')).toBeInTheDocument() - expect(screen.queryByText('My amazing button')).toBeInTheDocument() -}) +test("Renders", () => { + expect(screen.queryByText("My modal heading")).toBeInTheDocument(); + expect(screen.queryByText("Paragraph1")).toBeInTheDocument(); + expect(screen.queryByText("My amazing button")).toBeInTheDocument(); +}); -test('Clicking close button closes modal', () => { - const closeButton = screen.queryByTitle('modals.close') - fireEvent.click(closeButton) - expect(closeModal).toHaveBeenCalled() -}) +test("Clicking close button closes modal", () => { + const closeButton = screen.queryByTitle("modals.close"); + fireEvent.click(closeButton); + expect(closeModal).toHaveBeenCalled(); +}); -test('Pressing Enter calls the default callback', () => { - const modal = screen.getByRole('dialog') - fireEvent.keyDown(modal, {key: 'Enter'}) -}) +test("Pressing Enter calls the default callback", () => { + const modal = screen.getByRole("dialog"); + fireEvent.keyDown(modal, { key: "Enter" }); +}); diff --git a/src/components/Modals/InputModal.js b/src/components/Modals/InputModal.js index bd838c552..3a11aa81f 100644 --- a/src/components/Modals/InputModal.js +++ b/src/components/Modals/InputModal.js @@ -2,27 +2,37 @@ import React, { useCallback } from "react"; import GeneralModal from "./GeneralModal"; import NameErrorMessage from "../Editor/ErrorMessage/NameErrorMessage"; -const InputModal = ({inputLabel, inputDefaultValue, inputHelpText, ...otherProps}) => { - +const InputModal = ({ + inputLabel, + inputDefaultValue, + inputHelpText, + ...otherProps +}) => { const inputBox = useCallback((node) => { if (node) { - node.focus() + node.focus(); } - }, []) + }, []); return ( <GeneralModal {...otherProps}> <div> - <label htmlFor='name'>{inputLabel}</label> - <p className='modal-content__help-text'>{inputHelpText}</p> + <label htmlFor="name">{inputLabel}</label> + <p className="modal-content__help-text">{inputHelpText}</p> </div> - - <div className='modal-content__input'> + + <div className="modal-content__input"> <NameErrorMessage /> - <input ref={inputBox} type='text' name='name' id='name' defaultValue={inputDefaultValue}></input> + <input + ref={inputBox} + type="text" + name="name" + id="name" + defaultValue={inputDefaultValue} + ></input> </div> </GeneralModal> - ) -} + ); +}; -export default InputModal +export default InputModal; diff --git a/src/components/Modals/InputModal.test.js b/src/components/Modals/InputModal.test.js index e4a164784..64c9a7eb0 100644 --- a/src/components/Modals/InputModal.test.js +++ b/src/components/Modals/InputModal.test.js @@ -1,43 +1,43 @@ import React from "react"; -import configureStore from 'redux-mock-store'; +import configureStore from "redux-mock-store"; import { render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import InputModal from "./InputModal"; -let inputBox +let inputBox; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { - nameError: '' - } - } + nameError: "", + }, + }; const store = mockStore(initialState); render( <Provider store={store}> - <div id='app'> + <div id="app"> <InputModal isOpen={true} - inputDefaultValue='my amazing default' - inputLabel='input' - inputHelpText='help me' + inputDefaultValue="my amazing default" + inputLabel="input" + inputHelpText="help me" /> </div> - </Provider> - ) - inputBox = screen.getByLabelText('input') -}) + </Provider>, + ); + inputBox = screen.getByLabelText("input"); +}); -test('Renders help text', () => { - expect(screen.queryByText('help me')).toBeInTheDocument() -}) +test("Renders help text", () => { + expect(screen.queryByText("help me")).toBeInTheDocument(); +}); -test('Input renders with default value', () => { - expect(inputBox).toHaveValue('my amazing default') -}) +test("Input renders with default value", () => { + expect(inputBox).toHaveValue("my amazing default"); +}); -test('Focusses input box on load', () => { - expect(inputBox).toHaveFocus() -}) +test("Focusses input box on load", () => { + expect(inputBox).toHaveFocus(); +}); diff --git a/src/components/Modals/LoginToSaveModal.js b/src/components/Modals/LoginToSaveModal.js index 66231a401..1e0bf0aea 100644 --- a/src/components/Modals/LoginToSaveModal.js +++ b/src/components/Modals/LoginToSaveModal.js @@ -5,42 +5,55 @@ import { useTranslation } from "react-i18next"; import { closeLoginToSaveModal } from "../Editor/EditorSlice"; import DownloadButton from "../Header/DownloadButton"; import LoginButton from "../Login/LoginButton"; -import '../../Modal.scss'; +import "../../Modal.scss"; import Button from "../Button/Button"; import GeneralModal from "./GeneralModal"; import { login } from "../../utils/login"; import { useLocation } from "react-router-dom"; const LoginToSaveModal = () => { - const dispatch = useDispatch() - const { t } = useTranslation() - const location = useLocation() - const project = useSelector((state) => state.editor.project) - const isModalOpen = useSelector((state) => state.editor.loginToSaveModalShowing) + const dispatch = useDispatch(); + const { t } = useTranslation(); + const location = useLocation(); + const project = useSelector((state) => state.editor.project); + const isModalOpen = useSelector( + (state) => state.editor.loginToSaveModalShowing, + ); const closeModal = () => dispatch(closeLoginToSaveModal()); const defaultCallback = () => { - login({project, location, triggerSave: true}) - } + login({ project, location, triggerSave: true }); + }; return ( <GeneralModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('loginToSaveModal.heading')} + heading={t("loginToSaveModal.heading")} text={[ - {type: 'paragraph', content: t('loginToSaveModal.loginText')}, - {type: 'paragraph', content: t('loginToSaveModal.downloadText')} + { type: "paragraph", content: t("loginToSaveModal.loginText") }, + { type: "paragraph", content: t("loginToSaveModal.downloadText") }, ]} buttons={[ - <LoginButton className='btn--primary' buttonText={t('loginToSaveModal.loginButtonText')} triggerSave />, - <DownloadButton buttonText = {t('loginToSaveModal.downloadButtonText')} className = 'btn--secondary' />, - <Button buttonText = {t('loginToSaveModal.cancel')} className='btn--tertiary' onClickHandler={closeModal}/> + <LoginButton + className="btn--primary" + buttonText={t("loginToSaveModal.loginButtonText")} + triggerSave + />, + <DownloadButton + buttonText={t("loginToSaveModal.downloadButtonText")} + className="btn--secondary" + />, + <Button + buttonText={t("loginToSaveModal.cancel")} + className="btn--tertiary" + onClickHandler={closeModal} + />, ]} defaultCallback={defaultCallback} /> ); -} +}; export default LoginToSaveModal; diff --git a/src/components/Modals/LoginToSaveModal.test.js b/src/components/Modals/LoginToSaveModal.test.js index b16acd390..c16fe133b 100644 --- a/src/components/Modals/LoginToSaveModal.test.js +++ b/src/components/Modals/LoginToSaveModal.test.js @@ -1,37 +1,45 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import LoginToSaveModal from "./LoginToSaveModal"; -jest.mock('react-router-dom', () => ({ - useLocation: jest.fn() -})) +jest.mock("react-router-dom", () => ({ + useLocation: jest.fn(), +})); -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); -describe('When loginToSaveModalShowing is true', () => { - let store +describe("When loginToSaveModalShowing is true", () => { + let store; beforeEach(() => { const initialState = { editor: { loginToSaveModalShowing: true, - modals: {} - } - } + modals: {}, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><div id='app'><LoginToSaveModal /></div></Provider>) - }) + render( + <Provider store={store}> + <div id="app"> + <LoginToSaveModal /> + </div> + </Provider>, + ); + }); - test('Modal rendered', () => { - expect(screen.queryByText('loginToSaveModal.heading')).toBeInTheDocument() - }) + test("Modal rendered", () => { + expect(screen.queryByText("loginToSaveModal.heading")).toBeInTheDocument(); + }); - test('Clicking cancel dispatches close modal action', () => { - const cancelLink = screen.queryByText('loginToSaveModal.cancel') - fireEvent.click(cancelLink) - expect(store.getActions()).toEqual([{type: 'editor/closeLoginToSaveModal'}]) - }) -}) + test("Clicking cancel dispatches close modal action", () => { + const cancelLink = screen.queryByText("loginToSaveModal.cancel"); + fireEvent.click(cancelLink); + expect(store.getActions()).toEqual([ + { type: "editor/closeLoginToSaveModal" }, + ]); + }); +}); diff --git a/src/components/Modals/NewFileModal.js b/src/components/Modals/NewFileModal.js index 5edb09965..31e55c244 100644 --- a/src/components/Modals/NewFileModal.js +++ b/src/components/Modals/NewFileModal.js @@ -1,49 +1,66 @@ -import React from 'react' +import React from "react"; -import Button from '../Button/Button' -import { addProjectComponent, closeNewFileModal, openFile } from '../Editor/EditorSlice'; -import { useDispatch, useSelector } from 'react-redux'; -import { useTranslation } from 'react-i18next'; -import { validateFileName } from '../../utils/componentNameValidation'; -import InputModal from './InputModal'; +import Button from "../Button/Button"; +import { + addProjectComponent, + closeNewFileModal, + openFile, +} from "../Editor/EditorSlice"; +import { useDispatch, useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { validateFileName } from "../../utils/componentNameValidation"; +import InputModal from "./InputModal"; const NewFileModal = () => { - - const { t } = useTranslation() + const { t } = useTranslation(); const dispatch = useDispatch(); - const projectType = useSelector((state) => state.editor.project.project_type) - const projectComponents = useSelector((state) => state.editor.project.components); - const componentNames = projectComponents.map(component => `${component.name}.${component.extension}`) + const projectType = useSelector((state) => state.editor.project.project_type); + const projectComponents = useSelector( + (state) => state.editor.project.components, + ); + const componentNames = projectComponents.map( + (component) => `${component.name}.${component.extension}`, + ); - const isModalOpen = useSelector((state) => state.editor.newFileModalShowing) - const closeModal = () => dispatch(closeNewFileModal()) + const isModalOpen = useSelector((state) => state.editor.newFileModalShowing); + const closeModal = () => dispatch(closeNewFileModal()); const createComponent = () => { - const fileName = document.getElementById('name').value - const name = fileName.split('.')[0]; - const extension = fileName.split('.').slice(1).join('.'); + const fileName = document.getElementById("name").value; + const name = fileName.split(".")[0]; + const extension = fileName.split(".").slice(1).join("."); validateFileName(fileName, projectType, componentNames, dispatch, t, () => { - dispatch(addProjectComponent({extension: extension, name: name})); - dispatch(openFile(fileName)) + dispatch(addProjectComponent({ extension: extension, name: name })); + dispatch(openFile(fileName)); closeModal(); - }) - } + }); + }; return ( <InputModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('filePane.newFileModal.heading')} - inputLabel={t('filePane.newFileModal.inputLabel')} - inputHelpText={t('filePane.newFileModal.helpText', {examples: t(`filePane.newFileModal.helpTextExample.${projectType}`)})} + heading={t("filePane.newFileModal.heading")} + inputLabel={t("filePane.newFileModal.inputLabel")} + inputHelpText={t("filePane.newFileModal.helpText", { + examples: t(`filePane.newFileModal.helpTextExample.${projectType}`), + })} defaultCallback={createComponent} buttons={[ - <Button className='btn--primary' buttonText={t('filePane.newFileModal.addFile')} onClickHandler={createComponent} />, - <Button className='btn--secondary' buttonText={t('filePane.newFileModal.cancel')} onClickHandler={closeModal} /> + <Button + className="btn--primary" + buttonText={t("filePane.newFileModal.addFile")} + onClickHandler={createComponent} + />, + <Button + className="btn--secondary" + buttonText={t("filePane.newFileModal.cancel")} + onClickHandler={closeModal} + />, ]} /> - ) -} + ); +}; -export default NewFileModal +export default NewFileModal; diff --git a/src/components/Modals/NewFileModal.test.js b/src/components/Modals/NewFileModal.test.js index 90304944f..6f7fd1aac 100644 --- a/src/components/Modals/NewFileModal.test.js +++ b/src/components/Modals/NewFileModal.test.js @@ -1,10 +1,15 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import NewFileModal from "./NewFileModal"; -import { addProjectComponent, closeNewFileModal, openFile, setNameError } from "../Editor/EditorSlice"; +import { + addProjectComponent, + closeNewFileModal, + openFile, + setNameError, +} from "../Editor/EditorSlice"; describe("Testing the new file modal", () => { let store; @@ -12,68 +17,78 @@ describe("Testing the new file modal", () => { let inputBox; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { name: "main", - extension: "py" - } + extension: "py", + }, ], - project_type: "python" + project_type: "python", }, nameError: "", - newFileModalShowing: true - } - } + newFileModalShowing: true, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><div id='app'><NewFileModal /></div></Provider>) - saveButton = screen.getByText('filePane.newFileModal.addFile') - inputBox = screen.getByRole('textbox') - }) + render( + <Provider store={store}> + <div id="app"> + <NewFileModal /> + </div> + </Provider>, + ); + saveButton = screen.getByText("filePane.newFileModal.addFile"); + inputBox = screen.getByRole("textbox"); + }); - test('Modal renders',() => { - expect(screen.queryByText('filePane.newFileModal.heading')).toBeInTheDocument() - }) + test("Modal renders", () => { + expect( + screen.queryByText("filePane.newFileModal.heading"), + ).toBeInTheDocument(); + }); test("Pressing save adds new file with the given name", () => { - fireEvent.change(inputBox, {target: {value: "file1.py"}}) + fireEvent.change(inputBox, { target: { value: "file1.py" } }); inputBox.innerHTML = "file1.py"; - fireEvent.click(saveButton) + fireEvent.click(saveButton); const expectedActions = [ - addProjectComponent({extension: "py", name: "file1"}), - openFile('file1.py'), - closeNewFileModal() - ] + addProjectComponent({ extension: "py", name: "file1" }), + openFile("file1.py"), + closeNewFileModal(), + ]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Pressing Enter adds new file with the given name", () => { - fireEvent.change(inputBox, {target: {value: "file1.py"}}) + fireEvent.change(inputBox, { target: { value: "file1.py" } }); inputBox.innerHTML = "file1.py"; - fireEvent.keyDown(inputBox, { key: 'Enter'}) + fireEvent.keyDown(inputBox, { key: "Enter" }); const expectedActions = [ - addProjectComponent({extension: "py", name: "file1"}), - openFile('file1.py'), - closeNewFileModal() - ] + addProjectComponent({ extension: "py", name: "file1" }), + openFile("file1.py"), + closeNewFileModal(), + ]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Duplicate file names throws error", () => { - fireEvent.change(inputBox, {target: {value: "main.py"}}) - fireEvent.click(saveButton) - const expectedActions = [setNameError("filePane.errors.notUnique")] + fireEvent.change(inputBox, { target: { value: "main.py" } }); + fireEvent.click(saveButton); + const expectedActions = [setNameError("filePane.errors.notUnique")]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Unsupported extension throws error", () => { - fireEvent.change(inputBox, {target: {value: "file1.js"}}) - fireEvent.click(saveButton) - const expectedActions = [setNameError("filePane.errors.unsupportedExtension")] + fireEvent.change(inputBox, { target: { value: "file1.js" } }); + fireEvent.click(saveButton); + const expectedActions = [ + setNameError("filePane.errors.unsupportedExtension"), + ]; expect(store.getActions()).toEqual(expectedActions); - }) -}) + }); +}); diff --git a/src/components/Modals/NotFoundModal.js b/src/components/Modals/NotFoundModal.js index 3eda74818..e36c73e04 100644 --- a/src/components/Modals/NotFoundModal.js +++ b/src/components/Modals/NotFoundModal.js @@ -3,42 +3,55 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import Button from "../Button/Button"; -import '../../Modal.scss'; +import "../../Modal.scss"; import { closeNotFoundModal, syncProject } from "../Editor/EditorSlice"; import { defaultPythonProject } from "../../utils/defaultProjects"; import GeneralModal from "./GeneralModal"; const NotFoundModal = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { t } = useTranslation(); - const user = useSelector((state) => state.auth.user) - - const isModalOpen = useSelector((state) => state.editor.notFoundModalShowing) - const closeModal = () => dispatch(closeNotFoundModal()) + const user = useSelector((state) => state.auth.user); + + const isModalOpen = useSelector((state) => state.editor.notFoundModalShowing); + const closeModal = () => dispatch(closeNotFoundModal()); const createNewProject = async () => { if (user) { - dispatch(syncProject('save')({ project: defaultPythonProject, accessToken: user.access_token, autosave: false })) + dispatch( + syncProject("save")({ + project: defaultPythonProject, + accessToken: user.access_token, + autosave: false, + }), + ); } - closeModal() - } + closeModal(); + }; return ( <GeneralModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('project.notFoundModal.heading')} - text={[ - {type: 'paragraph', content: t('project.notFoundModal.text')} - ]} + heading={t("project.notFoundModal.heading")} + text={[{ type: "paragraph", content: t("project.notFoundModal.text") }]} buttons={[ - <Button className='btn--primary' buttonText={t('project.notFoundModal.newProject')} onClickHandler={createNewProject} />, - <a className='btn btn--secondary' href='https://projects.raspberrypi.org'>{t('project.notFoundModal.projectsSiteLinkText')}</a> + <Button + className="btn--primary" + buttonText={t("project.notFoundModal.newProject")} + onClickHandler={createNewProject} + />, + <a + className="btn btn--secondary" + href="https://projects.raspberrypi.org" + > + {t("project.notFoundModal.projectsSiteLinkText")} + </a>, ]} defaultCallback={createNewProject} /> ); -} +}; export default NotFoundModal; diff --git a/src/components/Modals/NotFoundModal.test.js b/src/components/Modals/NotFoundModal.test.js index 0fa07d079..c53dfb8f5 100644 --- a/src/components/Modals/NotFoundModal.test.js +++ b/src/components/Modals/NotFoundModal.test.js @@ -1,28 +1,28 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import NotFoundModal from "./NotFoundModal"; import { closeNotFoundModal, syncProject } from "../Editor/EditorSlice"; import { defaultPythonProject } from "../../utils/defaultProjects"; -jest.mock('../Editor/EditorSlice', () => ({ - ...jest.requireActual('../Editor/EditorSlice'), - syncProject: jest.fn((_) => jest.fn()) -})) +jest.mock("../Editor/EditorSlice", () => ({ + ...jest.requireActual("../Editor/EditorSlice"), + syncProject: jest.fn((_) => jest.fn()), +})); const user = { access_token: "39a09671-be55-4847-baf5-8919a0c24a25", profile: { - user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf" - } -} + user: "b48e70e2-d9ed-4a59-aee5-fc7cf09dbfaf", + }, +}; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); -describe('When logged in and notFoundModalShowing is true', () => { - let store +describe("When logged in and notFoundModalShowing is true", () => { + let store; beforeEach(() => { const initialState = { @@ -30,48 +30,68 @@ describe('When logged in and notFoundModalShowing is true', () => { notFoundModalShowing: true, }, auth: { - user: user - } - } + user: user, + }, + }; store = mockStore(initialState); - render(<Provider store={store}><div id='app'><NotFoundModal /></div></Provider>) - }) + render( + <Provider store={store}> + <div id="app"> + <NotFoundModal /> + </div> + </Provider>, + ); + }); - test('Modal rendered', () => { - expect(screen.queryByText('project.notFoundModal.heading')).toBeInTheDocument() - }) + test("Modal rendered", () => { + expect( + screen.queryByText("project.notFoundModal.heading"), + ).toBeInTheDocument(); + }); - test('Clicking new project creates a new project', async () => { - const newProjectLink = screen.queryByText('project.notFoundModal.newProject') - const saveAction = {type: 'SAVE_PROJECT' } - const saveProject = jest.fn(() => saveAction) - syncProject.mockImplementationOnce(jest.fn((_) => (saveProject))) - fireEvent.click(newProjectLink) - await waitFor(() => expect(saveProject).toHaveBeenCalledWith({ - project: defaultPythonProject, - accessToken: user.access_token, - autosave: false - })) - expect(store.getActions()[0]).toEqual(saveAction) - }) -}) + test("Clicking new project creates a new project", async () => { + const newProjectLink = screen.queryByText( + "project.notFoundModal.newProject", + ); + const saveAction = { type: "SAVE_PROJECT" }; + const saveProject = jest.fn(() => saveAction); + syncProject.mockImplementationOnce(jest.fn((_) => saveProject)); + fireEvent.click(newProjectLink); + await waitFor(() => + expect(saveProject).toHaveBeenCalledWith({ + project: defaultPythonProject, + accessToken: user.access_token, + autosave: false, + }), + ); + expect(store.getActions()[0]).toEqual(saveAction); + }); +}); -describe('When not logged in', () => { - let store +describe("When not logged in", () => { + let store; beforeEach(() => { const initialState = { editor: { notFoundModalShowing: true, }, - auth: {} - } + auth: {}, + }; store = mockStore(initialState); - render(<Provider store={store}><div id='app'><NotFoundModal /></div></Provider>) - }) - test('Clicking new project closes the modal', () => { - const newProjectLink = screen.queryByText('project.notFoundModal.newProject') - fireEvent.click(newProjectLink) - expect(store.getActions()).toEqual([closeNotFoundModal()]) - }) -}) + render( + <Provider store={store}> + <div id="app"> + <NotFoundModal /> + </div> + </Provider>, + ); + }); + test("Clicking new project closes the modal", () => { + const newProjectLink = screen.queryByText( + "project.notFoundModal.newProject", + ); + fireEvent.click(newProjectLink); + expect(store.getActions()).toEqual([closeNotFoundModal()]); + }); +}); diff --git a/src/components/Modals/RenameFile.js b/src/components/Modals/RenameFile.js index 55897541a..e4d7fa64d 100644 --- a/src/components/Modals/RenameFile.js +++ b/src/components/Modals/RenameFile.js @@ -3,47 +3,82 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import { validateFileName } from "../../utils/componentNameValidation"; import Button from "../Button/Button"; -import { closeRenameFileModal, updateComponentName } from "../Editor/EditorSlice"; -import '../../Modal.scss'; +import { + closeRenameFileModal, + updateComponentName, +} from "../Editor/EditorSlice"; +import "../../Modal.scss"; import InputModal from "./InputModal"; const RenameFile = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { t } = useTranslation(); - const projectType = useSelector((state) => state.editor.project.project_type) - const projectComponents = useSelector((state) => state.editor.project.components) - const isModalOpen = useSelector((state) => state.editor.renameFileModalShowing) - const {name: currentName, ext: currentExtension, fileKey} = useSelector((state) => state.editor.modals.renameFile); - const componentNames = projectComponents.map(component => `${component.name}.${component.extension}`) + const projectType = useSelector((state) => state.editor.project.project_type); + const projectComponents = useSelector( + (state) => state.editor.project.components, + ); + const isModalOpen = useSelector( + (state) => state.editor.renameFileModalShowing, + ); + const { + name: currentName, + ext: currentExtension, + fileKey, + } = useSelector((state) => state.editor.modals.renameFile); + const componentNames = projectComponents.map( + (component) => `${component.name}.${component.extension}`, + ); const closeModal = () => dispatch(closeRenameFileModal()); const renameComponent = () => { - const fileName = document.getElementById('name').value - const name = fileName.split('.')[0]; - const extension = fileName.split('.').slice(1).join('.'); + const fileName = document.getElementById("name").value; + const name = fileName.split(".")[0]; + const extension = fileName.split(".").slice(1).join("."); - validateFileName(fileName, projectType, componentNames, dispatch, t, () => { - dispatch(updateComponentName({key: fileKey, extension: extension, name: name})); - closeModal(); - }, `${currentName}.${currentExtension}`) - } + validateFileName( + fileName, + projectType, + componentNames, + dispatch, + t, + () => { + dispatch( + updateComponentName({ + key: fileKey, + extension: extension, + name: name, + }), + ); + closeModal(); + }, + `${currentName}.${currentExtension}`, + ); + }; return ( <InputModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('filePane.renameFileModal.heading')} - inputLabel={t('filePane.renameFileModal.inputLabel')} + heading={t("filePane.renameFileModal.heading")} + inputLabel={t("filePane.renameFileModal.inputLabel")} inputDefaultValue={`${currentName}.${currentExtension}`} defaultCallback={renameComponent} buttons={[ - <Button className='btn--primary' buttonText={t('filePane.renameFileModal.save')} onClickHandler={renameComponent} />, - <Button className='btn--secondary' buttonText={t('filePane.renameFileModal.cancel')} onClickHandler={closeModal} /> + <Button + className="btn--primary" + buttonText={t("filePane.renameFileModal.save")} + onClickHandler={renameComponent} + />, + <Button + className="btn--secondary" + buttonText={t("filePane.renameFileModal.cancel")} + onClickHandler={closeModal} + />, ]} /> ); -} +}; export default RenameFile; diff --git a/src/components/Modals/RenameFile.test.js b/src/components/Modals/RenameFile.test.js index 249e70a11..0a1be1d11 100644 --- a/src/components/Modals/RenameFile.test.js +++ b/src/components/Modals/RenameFile.test.js @@ -1,10 +1,14 @@ import React from "react"; -import { fireEvent, render } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import RenameFile from "./RenameFile"; -import { setNameError, updateComponentName, closeRenameFileModal } from "../Editor/EditorSlice"; +import { + setNameError, + updateComponentName, + closeRenameFileModal, +} from "../Editor/EditorSlice"; describe("Testing the rename file modal", () => { let store; @@ -13,86 +17,94 @@ describe("Testing the rename file modal", () => { let getByText; beforeEach(() => { - const middlewares = [] - const mockStore = configureStore(middlewares) + const middlewares = []; + const mockStore = configureStore(middlewares); const initialState = { editor: { project: { components: [ { name: "main", - extension: "py" + extension: "py", }, { name: "my_file", - extension: "py" - } + extension: "py", + }, ], - project_type: "python" + project_type: "python", }, nameError: "", modals: { renameFile: { - name: 'main', - ext: 'py', - fileKey: 0 - } + name: "main", + ext: "py", + fileKey: 0, + }, }, - renameFileModalShowing: true - } - } + renameFileModalShowing: true, + }, + }; store = mockStore(initialState); - ({getByText} = render(<Provider store={store}><div id='app'><RenameFile currentName='main' currentExtension='py' fileKey={0} /></div></Provider>)) - inputBox = document.getElementById('name') - saveButton = getByText('filePane.renameFileModal.save'); - }) + ({ getByText } = render( + <Provider store={store}> + <div id="app"> + <RenameFile currentName="main" currentExtension="py" fileKey={0} /> + </div> + </Provider>, + )); + inputBox = document.getElementById("name"); + saveButton = getByText("filePane.renameFileModal.save"); + }); - test('State being set displays the modal', () => { - expect(getByText('filePane.renameFileModal.heading')).toBeInTheDocument() - }) + test("State being set displays the modal", () => { + expect(getByText("filePane.renameFileModal.heading")).toBeInTheDocument(); + }); test("Pressing save renames the file to the given name", () => { - fireEvent.change(inputBox, {target: {value: "file1.py"}}) + fireEvent.change(inputBox, { target: { value: "file1.py" } }); inputBox.innerHTML = "file1.py"; - fireEvent.click(saveButton) + fireEvent.click(saveButton); const expectedActions = [ - updateComponentName({key: 0, extension: "py", name: "file1"}), - closeRenameFileModal() - ] + updateComponentName({ key: 0, extension: "py", name: "file1" }), + closeRenameFileModal(), + ]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Pressing Enter renames the file to the given name", () => { - fireEvent.change(inputBox, {target: {value: "file1.py"}}) + fireEvent.change(inputBox, { target: { value: "file1.py" } }); inputBox.innerHTML = "file1.py"; - fireEvent.keyDown(inputBox, { key: 'Enter'}) + fireEvent.keyDown(inputBox, { key: "Enter" }); const expectedActions = [ - updateComponentName({key: 0, extension: "py", name: "file1"}), - closeRenameFileModal() - ] + updateComponentName({ key: 0, extension: "py", name: "file1" }), + closeRenameFileModal(), + ]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Duplicate file names throws error", () => { - fireEvent.change(inputBox, {target: {value: "my_file.py"}}) - fireEvent.click(saveButton) - const expectedActions = [setNameError('filePane.errors.notUnique')] + fireEvent.change(inputBox, { target: { value: "my_file.py" } }); + fireEvent.click(saveButton); + const expectedActions = [setNameError("filePane.errors.notUnique")]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Unchanged file name does not throw error", () => { - fireEvent.click(saveButton) + fireEvent.click(saveButton); const expectedActions = [ - updateComponentName({key: 0, extension: "py", name: "main"}), - closeRenameFileModal() - ] + updateComponentName({ key: 0, extension: "py", name: "main" }), + closeRenameFileModal(), + ]; expect(store.getActions()).toEqual(expectedActions); - }) + }); test("Unsupported extension throws error", () => { - fireEvent.change(inputBox, {target: {value: "file1.js"}}) - fireEvent.click(saveButton) - const expectedActions = [setNameError("filePane.errors.unsupportedExtension")] + fireEvent.change(inputBox, { target: { value: "file1.js" } }); + fireEvent.click(saveButton); + const expectedActions = [ + setNameError("filePane.errors.unsupportedExtension"), + ]; expect(store.getActions()).toEqual(expectedActions); - }) -}) + }); +}); diff --git a/src/components/Modals/RenameProjectModal.js b/src/components/Modals/RenameProjectModal.js index 2cf86d614..99f87e648 100644 --- a/src/components/Modals/RenameProjectModal.js +++ b/src/components/Modals/RenameProjectModal.js @@ -1,15 +1,15 @@ import React from "react"; -import { gql, useMutation } from '@apollo/client'; +import { gql, useMutation } from "@apollo/client"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { closeRenameProjectModal } from "../Editor/EditorSlice"; -import { showRenamedMessage } from '../../utils/Notifications'; +import { showRenamedMessage } from "../../utils/Notifications"; import Button from "../Button/Button"; import InputModal from "./InputModal"; export const RENAME_PROJECT_MUTATION = gql` mutation RenameProject($id: String!, $name: String!) { - updateProject(input: {id: $id, name: $name}) { + updateProject(input: { id: $id, name: $name }) { project { id name @@ -20,41 +20,54 @@ export const RENAME_PROJECT_MUTATION = gql` `; export const RenameProjectModal = () => { - const dispatch = useDispatch() + const dispatch = useDispatch(); const { t } = useTranslation(); - const isModalOpen = useSelector((state) => state.editor.renameProjectModalShowing) - const project = useSelector((state) => state.editor.modals.renameProject) - const closeModal = () => dispatch(closeRenameProjectModal()) + const isModalOpen = useSelector( + (state) => state.editor.renameProjectModalShowing, + ); + const project = useSelector((state) => state.editor.modals.renameProject); + const closeModal = () => dispatch(closeRenameProjectModal()); const onCompleted = () => { - closeModal() - showRenamedMessage() - } + closeModal(); + showRenamedMessage(); + }; // This can capture data, error, loading as per normal queries, but we're not // using them yet. const [renameProjectMutation] = useMutation(RENAME_PROJECT_MUTATION); const renameProject = () => { - const newName = document.getElementById('name').value - renameProjectMutation({variables: {id: project.id, name: newName}, onCompleted: onCompleted}) - } + const newName = document.getElementById("name").value; + renameProjectMutation({ + variables: { id: project.id, name: newName }, + onCompleted: onCompleted, + }); + }; return ( <InputModal isOpen={isModalOpen} closeModal={closeModal} withCloseButton - heading={t('projectList.renameProjectModal.heading')} - inputLabel={t('projectList.renameProjectModal.inputLabel')} + heading={t("projectList.renameProjectModal.heading")} + inputLabel={t("projectList.renameProjectModal.inputLabel")} inputDefaultValue={project.name} defaultCallback={renameProject} buttons={[ - <Button className='btn--primary' buttonText={t('projectList.renameProjectModal.save')} onClickHandler={renameProject} />, - <Button className='btn--secondary' buttonText={t('projectList.renameProjectModal.cancel')} onClickHandler={closeModal} /> + <Button + className="btn--primary" + buttonText={t("projectList.renameProjectModal.save")} + onClickHandler={renameProject} + />, + <Button + className="btn--secondary" + buttonText={t("projectList.renameProjectModal.cancel")} + onClickHandler={closeModal} + />, ]} /> ); -} +}; -export default RenameProjectModal +export default RenameProjectModal; diff --git a/src/components/Modals/RenameProjectModal.test.js b/src/components/Modals/RenameProjectModal.test.js index 89357b8a0..86bc364c3 100644 --- a/src/components/Modals/RenameProjectModal.test.js +++ b/src/components/Modals/RenameProjectModal.test.js @@ -1,116 +1,135 @@ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import { MockedProvider } from "@apollo/client/testing"; -import { RenameProjectModal, RENAME_PROJECT_MUTATION } from "./RenameProjectModal"; -import { showRenamedMessage } from '../../utils/Notifications'; +import { + RenameProjectModal, + RENAME_PROJECT_MUTATION, +} from "./RenameProjectModal"; +import { showRenamedMessage } from "../../utils/Notifications"; -jest.mock('../../utils/Notifications') +jest.mock("../../utils/Notifications"); describe("RenameProjectModal", () => { - let store - let inputBox - let saveButton + let store; + let inputBox; + let saveButton; let project = { - name: 'my first project', - id: 'XYZ' - } - let newName = 'renamed project' - let mocks + name: "my first project", + id: "XYZ", + }; + let newName = "renamed project"; + let mocks; beforeEach(() => { mocks = [ { request: { query: RENAME_PROJECT_MUTATION, - variables: { id: project.id, name: newName } + variables: { id: project.id, name: newName }, }, result: jest.fn(() => ({ data: { id: project.id, name: newName, - updatedAt: '2023-02-257T14:48:00Z' - } - })) - } - ] + updatedAt: "2023-02-257T14:48:00Z", + }, + })), + }, + ]; - const mockStore = configureStore([]) + const mockStore = configureStore([]); const initialState = { editor: { modals: { - renameProject: project + renameProject: project, }, - renameProjectModalShowing: true - } - } + renameProjectModalShowing: true, + }, + }; store = mockStore(initialState); render( <MockedProvider mocks={mocks}> <Provider store={store}> - <div id='app'><RenameProjectModal /></div> + <div id="app"> + <RenameProjectModal /> + </div> </Provider> - </MockedProvider> - ) + </MockedProvider>, + ); - inputBox = screen.getByRole('textbox') - saveButton = screen.getByText('projectList.renameProjectModal.save') - }) + inputBox = screen.getByRole("textbox"); + saveButton = screen.getByText("projectList.renameProjectModal.save"); + }); - test('Modal renders', () => { - expect(screen.getByText('projectList.renameProjectModal.heading')).not.toBeNull() - }) + test("Modal renders", () => { + expect( + screen.getByText("projectList.renameProjectModal.heading"), + ).not.toBeNull(); + }); - test('Clicking cancel button closes modal and does not save', () => { - const cancelButton = screen.queryByText('projectList.renameProjectModal.cancel') - fireEvent.click(cancelButton) - expect(store.getActions()).toEqual([{type: 'editor/closeRenameProjectModal'}]) - }) + test("Clicking cancel button closes modal and does not save", () => { + const cancelButton = screen.queryByText( + "projectList.renameProjectModal.cancel", + ); + fireEvent.click(cancelButton); + expect(store.getActions()).toEqual([ + { type: "editor/closeRenameProjectModal" }, + ]); + }); - describe('Clicking save', () => { + describe("Clicking save", () => { let renameProjectMutationMock; beforeEach(() => { - renameProjectMutationMock = mocks[0].result - fireEvent.change(inputBox, {target: {value: "renamed project"}}) - fireEvent.click(saveButton) - }) + renameProjectMutationMock = mocks[0].result; + fireEvent.change(inputBox, { target: { value: "renamed project" } }); + fireEvent.click(saveButton); + }); test("Calls the mutation", async () => { - await waitFor(() => expect(renameProjectMutationMock).toHaveBeenCalled()) - }) - + await waitFor(() => expect(renameProjectMutationMock).toHaveBeenCalled()); + }); + test("Eventually closes the modal", async () => { - await waitFor(() => expect(store.getActions()).toEqual([{type: 'editor/closeRenameProjectModal'}])) - }) - + await waitFor(() => + expect(store.getActions()).toEqual([ + { type: "editor/closeRenameProjectModal" }, + ]), + ); + }); + test("Eventually pops up the toast notification", async () => { - await waitFor(() => expect(showRenamedMessage).toHaveBeenCalled()) - }) - }) + await waitFor(() => expect(showRenamedMessage).toHaveBeenCalled()); + }); + }); - describe('Pressing Enter', () => { + describe("Pressing Enter", () => { let renameProjectMutationMock; beforeEach(() => { - renameProjectMutationMock = mocks[0].result - fireEvent.change(inputBox, {target: {value: "renamed project"}}) - fireEvent.keyDown(inputBox, { key: 'Enter'}) - }) + renameProjectMutationMock = mocks[0].result; + fireEvent.change(inputBox, { target: { value: "renamed project" } }); + fireEvent.keyDown(inputBox, { key: "Enter" }); + }); test("Calls the mutation", async () => { - await waitFor(() => expect(renameProjectMutationMock).toHaveBeenCalled()) - }) - + await waitFor(() => expect(renameProjectMutationMock).toHaveBeenCalled()); + }); + test("Eventually closes the modal", async () => { - await waitFor(() => expect(store.getActions()).toEqual([{type: 'editor/closeRenameProjectModal'}])) - }) - + await waitFor(() => + expect(store.getActions()).toEqual([ + { type: "editor/closeRenameProjectModal" }, + ]), + ); + }); + test("Eventually pops up the toast notification", async () => { - await waitFor(() => expect(showRenamedMessage).toHaveBeenCalled()) - }) - }) -}) + await waitFor(() => expect(showRenamedMessage).toHaveBeenCalled()); + }); + }); +}); diff --git a/src/components/ProjectIndex/ProjectIndex.js b/src/components/ProjectIndex/ProjectIndex.js index a460beaf2..a6568e3f2 100644 --- a/src/components/ProjectIndex/ProjectIndex.js +++ b/src/components/ProjectIndex/ProjectIndex.js @@ -51,16 +51,16 @@ const ProjectIndex = (props) => { useRequiresUser(isLoading, user); const renameProjectModalShowing = useSelector( - (state) => state.editor.renameProjectModalShowing + (state) => state.editor.renameProjectModalShowing, ); const deleteProjectModalShowing = useSelector( - (state) => state.editor.deleteProjectModalShowing + (state) => state.editor.deleteProjectModalShowing, ); const onCreateProject = async () => { const response = await createOrUpdateProject( defaultPythonProject, - user.access_token + user.access_token, ); const identifier = response.data.identifier; const locale = i18n.language; diff --git a/src/components/ProjectIndex/ProjectIndex.test.js b/src/components/ProjectIndex/ProjectIndex.test.js index 1c27f6038..339a1996e 100644 --- a/src/components/ProjectIndex/ProjectIndex.test.js +++ b/src/components/ProjectIndex/ProjectIndex.test.js @@ -94,7 +94,7 @@ describe("When authenticated", () => { <ProjectIndex user={user} isLoading={false} /> </MockedProvider> </MemoryRouter> - </Provider> + </Provider>, ); }); @@ -120,7 +120,7 @@ describe("When unauthenticated", () => { <Provider store={store}> <ProjectIndex /> </Provider> - </MockedProvider> + </MockedProvider>, ); }); diff --git a/src/components/ProjectIndex/ProjectIndexPagination.test.js b/src/components/ProjectIndex/ProjectIndexPagination.test.js index 2f7a6cb49..e37ad480c 100644 --- a/src/components/ProjectIndex/ProjectIndexPagination.test.js +++ b/src/components/ProjectIndex/ProjectIndexPagination.test.js @@ -17,13 +17,13 @@ describe("When pageInfo is missing", () => { pageSize={pageSize} paginationData={paginationData} fetchMore={fetchMore} - /> + />, ); }); test("It doesn't show the navigation", () => { expect( - screen.queryByTestId("projectIndexPagination") + screen.queryByTestId("projectIndexPagination"), ).not.toBeInTheDocument(); }); }); @@ -39,13 +39,13 @@ describe("When totalCount is missing", () => { pageSize={pageSize} paginationData={paginationData} fetchMore={fetchMore} - /> + />, ); }); test("It doesn't show the navigation", () => { expect( - screen.queryByTestId("projectIndexPagination") + screen.queryByTestId("projectIndexPagination"), ).not.toBeInTheDocument(); }); }); @@ -66,13 +66,13 @@ describe("When on the first page of projects", () => { pageSize={pageSize} paginationData={paginationData} fetchMore={fetchMore} - /> + />, ); }); test("More buttons shown", () => { expect( - screen.queryByTitle("projectList.pagination.more") + screen.queryByTitle("projectList.pagination.more"), ).toBeInTheDocument(); }); }); @@ -91,13 +91,13 @@ describe("When the endCursor is missing", () => { pageSize={pageSize} paginationData={paginationData} fetchMore={fetchMore} - /> + />, ); }); test("Assume there is more to load", () => { expect( - screen.queryByTitle("projectList.pagination.more") + screen.queryByTitle("projectList.pagination.more"), ).toBeInTheDocument(); }); }); @@ -119,13 +119,13 @@ describe("When on a middle page of projects", () => { pageSize={pageSize} paginationData={paginationData} fetchMore={fetchMore} - /> + />, ); }); test("More buttons shown", () => { expect( - screen.queryByTitle("projectList.pagination.more") + screen.queryByTitle("projectList.pagination.more"), ).toBeInTheDocument(); }); @@ -154,13 +154,13 @@ describe("When on the last page of projects", () => { pageSize={pageSize} paginationData={paginationData} fetchMore={fetchMore} - /> + />, ); }); test("More button not shown", () => { expect( - screen.queryByTitle("projectList.pagination.more") + screen.queryByTitle("projectList.pagination.more"), ).not.toBeInTheDocument(); }); }); diff --git a/src/components/ProjectIndexHeader/ProjectIndexHeader.js b/src/components/ProjectIndexHeader/ProjectIndexHeader.js index 9f8558935..b4408c803 100644 --- a/src/components/ProjectIndexHeader/ProjectIndexHeader.js +++ b/src/components/ProjectIndexHeader/ProjectIndexHeader.js @@ -1,21 +1,20 @@ -import { useTranslation } from 'react-i18next'; -import './ProjectIndexHeader.scss' - +import { useTranslation } from "react-i18next"; +import "./ProjectIndexHeader.scss"; const ProjectIndexHeader = (props) => { - const { t } = useTranslation() + const { t } = useTranslation(); return ( - <header className='editor-project-header'> - <div className='editor-project-header__inner'> - <div className='editor-project-header__content'> - <h2>{t('projectHeader.subTitle')}</h2> - <h1 className='editor-project-header__title'>{t('projectHeader.title')}</h1> - <h3>{t('projectHeader.text')}</h3> - </div> - <div className='editor-project-header__action'> - {props.children} + <header className="editor-project-header"> + <div className="editor-project-header__inner"> + <div className="editor-project-header__content"> + <h2>{t("projectHeader.subTitle")}</h2> + <h1 className="editor-project-header__title"> + {t("projectHeader.title")} + </h1> + <h3>{t("projectHeader.text")}</h3> </div> + <div className="editor-project-header__action">{props.children}</div> </div> </header> ); diff --git a/src/components/ProjectListItem/ProjectListItem.js b/src/components/ProjectListItem/ProjectListItem.js index f16682955..948da4f31 100644 --- a/src/components/ProjectListItem/ProjectListItem.js +++ b/src/components/ProjectListItem/ProjectListItem.js @@ -32,7 +32,7 @@ export const ProjectListItem = (props) => { const lastSaved = intlFormatDistance( new Date(project.updatedAt), Date.now(), - { style: "short" } + { style: "short" }, ); const projectType = props.project.projectType; diff --git a/src/components/ProjectListItem/ProjectListItem.test.js b/src/components/ProjectListItem/ProjectListItem.test.js index 2cd2e621d..710ac5276 100644 --- a/src/components/ProjectListItem/ProjectListItem.test.js +++ b/src/components/ProjectListItem/ProjectListItem.test.js @@ -2,34 +2,48 @@ import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router-dom"; -import configureStore from 'redux-mock-store'; +import configureStore from "redux-mock-store"; import { ProjectListItem } from "./ProjectListItem"; -jest.mock('date-fns') +jest.mock("date-fns"); -let store -let project = { identifier: 'hello-world-project', name: 'my amazing project', updatedAt: Date.now() } +let store; +let project = { + identifier: "hello-world-project", + name: "my amazing project", + updatedAt: Date.now(), +}; beforeEach(() => { - const mockStore = configureStore([]) - const initialState = {} + const mockStore = configureStore([]); + const initialState = {}; store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><ProjectListItem project = {project}/></MemoryRouter></Provider>) -}) + render( + <Provider store={store}> + <MemoryRouter> + <ProjectListItem project={project} /> + </MemoryRouter> + </Provider>, + ); +}); -test('Renders project name', () => { - expect(screen.queryByText(project.name)).toBeInTheDocument() -}) +test("Renders project name", () => { + expect(screen.queryByText(project.name)).toBeInTheDocument(); +}); -test('Clicking rename button opens rename project modal', () => { - const renameButtons = screen.queryAllByText('projectList.rename') - fireEvent.click(renameButtons[0]) - expect(store.getActions()).toEqual([{type: "editor/showRenameProjectModal", payload: project}]) -}) +test("Clicking rename button opens rename project modal", () => { + const renameButtons = screen.queryAllByText("projectList.rename"); + fireEvent.click(renameButtons[0]); + expect(store.getActions()).toEqual([ + { type: "editor/showRenameProjectModal", payload: project }, + ]); +}); -test('Clicking delete button opens delete project modal', () => { - const deleteButtons = screen.queryAllByText('projectList.delete') - fireEvent.click(deleteButtons[0]) - expect(store.getActions()).toEqual([{type: "editor/showDeleteProjectModal", payload: project}]) -}) +test("Clicking delete button opens delete project modal", () => { + const deleteButtons = screen.queryAllByText("projectList.delete"); + fireEvent.click(deleteButtons[0]); + expect(store.getActions()).toEqual([ + { type: "editor/showDeleteProjectModal", payload: project }, + ]); +}); diff --git a/src/components/ProjectListTable/ProjectListTable.js b/src/components/ProjectListTable/ProjectListTable.js index 910dff231..03cc16c08 100644 --- a/src/components/ProjectListTable/ProjectListTable.js +++ b/src/components/ProjectListTable/ProjectListTable.js @@ -1,7 +1,10 @@ -import { useTranslation } from 'react-i18next'; -import { ProjectListItem, PROJECT_LIST_ITEM_FRAGMENT } from '../ProjectListItem/ProjectListItem' -import './ProjectListTable.scss' -import { gql } from '@apollo/client'; +import { useTranslation } from "react-i18next"; +import { + ProjectListItem, + PROJECT_LIST_ITEM_FRAGMENT, +} from "../ProjectListItem/ProjectListItem"; +import "./ProjectListTable.scss"; +import { gql } from "@apollo/client"; export const PROJECT_LIST_TABLE_FRAGMENT = gql` fragment ProjectListTableFragment on ProjectConnection { @@ -16,7 +19,6 @@ export const PROJECT_LIST_TABLE_FRAGMENT = gql` ${PROJECT_LIST_ITEM_FRAGMENT} `; - export const ProjectListTable = (props) => { const { t } = useTranslation(); const { projectData } = props; @@ -24,22 +26,20 @@ export const ProjectListTable = (props) => { const projectList = projectData?.edges?.map((edge) => edge.node); return ( - <div className='editor-project-list'> - <div className='editor-project-list__container'> - { projectList && projectList.length > 0 ? + <div className="editor-project-list"> + <div className="editor-project-list__container"> + {projectList && projectList.length > 0 ? ( <> - { projectList.map((project, i) => ( - <ProjectListItem project={project} key={i}/> - ) - )} + {projectList.map((project, i) => ( + <ProjectListItem project={project} key={i} /> + ))} </> - : - <div className='editor-project-list__empty'> - <p>{t('projectList.empty')}</p> + ) : ( + <div className="editor-project-list__empty"> + <p>{t("projectList.empty")}</p> </div> - } + )} </div> </div> - ) + ); }; - diff --git a/src/components/ProjectListTable/ProjectListTable.test.js b/src/components/ProjectListTable/ProjectListTable.test.js index 212b01314..811828a22 100644 --- a/src/components/ProjectListTable/ProjectListTable.test.js +++ b/src/components/ProjectListTable/ProjectListTable.test.js @@ -1,50 +1,64 @@ -import { render, screen } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import configureStore from 'redux-mock-store'; -import { ProjectListTable } from './ProjectListTable'; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { MemoryRouter } from "react-router-dom"; +import configureStore from "redux-mock-store"; +import { ProjectListTable } from "./ProjectListTable"; -describe('When the logged in user has projects', () => { +describe("When the logged in user has projects", () => { const project = { - name: 'hello world', - project_type: 'python', - identifier: 'hello-world-project', - updatedAt: Date.now() - } + name: "hello world", + project_type: "python", + identifier: "hello-world-project", + updatedAt: Date.now(), + }; const projectData = { - edges: [{ - cursor: "Mq", - node: { ...project } - }] - } + edges: [ + { + cursor: "Mq", + node: { ...project }, + }, + ], + }; beforeEach(() => { const mockStore = configureStore([]); - const initialState = {} - const store = mockStore(initialState) + const initialState = {}; + const store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><ProjectListTable projectData = { projectData } /></MemoryRouter></Provider>); + render( + <Provider store={store}> + <MemoryRouter> + <ProjectListTable projectData={projectData} /> + </MemoryRouter> + </Provider>, + ); }); - test('The projects page show a list of projects', () => { + test("The projects page show a list of projects", () => { expect(screen.queryByText(project.name)).toBeInTheDocument(); }); }); -describe('When the logged in user has no projects', () => { +describe("When the logged in user has no projects", () => { const projectData = { - edges: [] - } + edges: [], + }; beforeEach(() => { const mockStore = configureStore([]); - const initialState = { } + const initialState = {}; const store = mockStore(initialState); - render(<Provider store={store}><MemoryRouter><ProjectListTable projectData = { projectData } /></MemoryRouter></Provider>); + render( + <Provider store={store}> + <MemoryRouter> + <ProjectListTable projectData={projectData} /> + </MemoryRouter> + </Provider>, + ); }); - test('The projects page show an empty state message', () => { - expect(screen.queryByText('projectList.empty')).toBeInTheDocument(); + test("The projects page show an empty state message", () => { + expect(screen.queryByText("projectList.empty")).toBeInTheDocument(); }); }); diff --git a/src/components/ProjectViewer/ProjectViewer.js b/src/components/ProjectViewer/ProjectViewer.js index 194891e05..e34b44d2a 100644 --- a/src/components/ProjectViewer/ProjectViewer.js +++ b/src/components/ProjectViewer/ProjectViewer.js @@ -1,15 +1,15 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux' -import { useProject } from '../Editor/Hooks/useProject' -import PythonRunner from '../Editor/Runners/PythonRunner/PythonRunner' -import { triggerCodeRun } from '../Editor/EditorSlice' -import RunnerControls from '../RunButton/RunnerControls'; -import { useParams } from 'react-router-dom'; +import React, { useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { useProject } from "../Editor/Hooks/useProject"; +import PythonRunner from "../Editor/Runners/PythonRunner/PythonRunner"; +import { triggerCodeRun } from "../Editor/EditorSlice"; +import RunnerControls from "../RunButton/RunnerControls"; +import { useParams } from "react-router-dom"; const ProjectViewer = () => { const loading = useSelector((state) => state.editor.loading); - const { identifier } = useParams() + const { identifier } = useParams(); const dispatch = useDispatch(); useProject(identifier); @@ -17,10 +17,9 @@ const ProjectViewer = () => { dispatch(triggerCodeRun()); }, []); - - return loading === 'success' ? ( + return loading === "success" ? ( <> - <div className='main-container'> + <div className="main-container"> <h1>Shared project</h1> <RunnerControls /> <div> @@ -28,7 +27,9 @@ const ProjectViewer = () => { </div> </div> </> - ) : <p>Loading</p>; + ) : ( + <p>Loading</p> + ); }; export default ProjectViewer; diff --git a/src/components/RunButton/RunBar.js b/src/components/RunButton/RunBar.js index 84e4d2923..85ec8d830 100644 --- a/src/components/RunButton/RunBar.js +++ b/src/components/RunButton/RunBar.js @@ -1,14 +1,14 @@ import React from "react"; import RunnerControls from "./RunnerControls"; -import './RunBar.scss'; +import "./RunBar.scss"; const RunBar = () => { return ( - <div className='run-bar'> + <div className="run-bar"> <RunnerControls /> </div> - ) -} + ); +}; -export default RunBar +export default RunBar; diff --git a/src/components/RunButton/RunBar.test.js b/src/components/RunButton/RunBar.test.js index 783ee492e..9fc053717 100644 --- a/src/components/RunButton/RunBar.test.js +++ b/src/components/RunButton/RunBar.test.js @@ -1,19 +1,23 @@ import React from "react"; -import { render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import RunBar from "./RunBar"; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); const initialState = { editor: { - codeRunTriggered: false - } -} -const store = mockStore(initialState) + codeRunTriggered: false, + }, +}; +const store = mockStore(initialState); test("Renders", () => { - render(<Provider store={store}><RunBar/></Provider>) - expect(screen.queryByRole('button')).toHaveTextContent('runButton.run') -}) + render( + <Provider store={store}> + <RunBar /> + </Provider>, + ); + expect(screen.queryByRole("button")).toHaveTextContent("runButton.run"); +}); diff --git a/src/components/RunButton/RunButton.js b/src/components/RunButton/RunButton.js index bdacec73c..06dd975b8 100644 --- a/src/components/RunButton/RunButton.js +++ b/src/components/RunButton/RunButton.js @@ -1,23 +1,26 @@ -import Button from '../Button/Button' +import Button from "../Button/Button"; -import React from 'react'; -import { useDispatch } from 'react-redux' -import { triggerCodeRun } from '../Editor/EditorSlice' +import React from "react"; +import { useDispatch } from "react-redux"; +import { triggerCodeRun } from "../Editor/EditorSlice"; const RunButton = (props) => { const dispatch = useDispatch(); const onClickRun = () => { if (window.plausible) { - window.plausible('Run button') + window.plausible("Run button"); } dispatch(triggerCodeRun()); - } + }; return ( - <Button className={"btn--primary btn--run"} onClickHandler={onClickRun} {...props} /> - ) + <Button + className={"btn--primary btn--run"} + onClickHandler={onClickRun} + {...props} + /> + ); }; export default RunButton; - diff --git a/src/components/RunButton/RunButton.test.js b/src/components/RunButton/RunButton.test.js index f37e8730a..02c7aecec 100644 --- a/src/components/RunButton/RunButton.test.js +++ b/src/components/RunButton/RunButton.test.js @@ -1,14 +1,18 @@ import React from "react"; -import { render, screen } from "@testing-library/react" -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; import RunButton from "./RunButton"; -const middlewares = [] -const mockStore = configureStore(middlewares) -const store = mockStore({}) +const middlewares = []; +const mockStore = configureStore(middlewares); +const store = mockStore({}); test("Run button renders with expected button text", () => { - render(<Provider store={store}><RunButton buttonText="Run Code" /></Provider>) - expect(screen.queryByRole('button')).toHaveTextContent('Run Code') -}) + render( + <Provider store={store}> + <RunButton buttonText="Run Code" /> + </Provider>, + ); + expect(screen.queryByRole("button")).toHaveTextContent("Run Code"); +}); diff --git a/src/components/RunButton/RunnerControls.js b/src/components/RunButton/RunnerControls.js index b2493096a..a20efde69 100644 --- a/src/components/RunButton/RunnerControls.js +++ b/src/components/RunButton/RunnerControls.js @@ -1,21 +1,30 @@ -import React from 'react'; +import React from "react"; import RunButton from "./RunButton"; import StopButton from "./StopButton"; -import { useSelector } from 'react-redux'; -import { RunIcon, StopIcon } from '../../Icons'; -import { useTranslation } from 'react-i18next'; +import { useSelector } from "react-redux"; +import { RunIcon, StopIcon } from "../../Icons"; +import { useTranslation } from "react-i18next"; const RunnerControls = () => { - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered); + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); const drawTriggered = useSelector((state) => state.editor.drawTriggered); - const { t } = useTranslation() + const { t } = useTranslation(); - return ( - (codeRunTriggered || drawTriggered) ? - <StopButton buttonText={t('runButton.stop')} ButtonIcon={StopIcon} buttonIconPosition = 'right' /> - : - <RunButton buttonText={t('runButton.run')} ButtonIcon={RunIcon} buttonIconPosition = 'right' /> - ) -} + return codeRunTriggered || drawTriggered ? ( + <StopButton + buttonText={t("runButton.stop")} + ButtonIcon={StopIcon} + buttonIconPosition="right" + /> + ) : ( + <RunButton + buttonText={t("runButton.run")} + ButtonIcon={RunIcon} + buttonIconPosition="right" + /> + ); +}; export default RunnerControls; diff --git a/src/components/RunButton/RunnerControls.test.js b/src/components/RunButton/RunnerControls.test.js index 3715bf46b..8575b862d 100644 --- a/src/components/RunButton/RunnerControls.test.js +++ b/src/components/RunButton/RunnerControls.test.js @@ -1,34 +1,42 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { Provider } from 'react-redux'; +import { Provider } from "react-redux"; import RunnerControls from "./RunnerControls"; import configureStore from "redux-mock-store"; -const middlewares = [] -const mockStore = configureStore(middlewares) +const middlewares = []; +const mockStore = configureStore(middlewares); test("Run button shows when code is not running", () => { - const initialState = { - editor: { - codeRunTriggered: false, - codeRunStopped: false - } - } - const store = mockStore(initialState) - render(<Provider store={store}><RunnerControls /></Provider>); - const runButton = screen.queryByText('runButton.run'); - expect(runButton).toBeInTheDocument() -}) + const initialState = { + editor: { + codeRunTriggered: false, + codeRunStopped: false, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <RunnerControls /> + </Provider>, + ); + const runButton = screen.queryByText("runButton.run"); + expect(runButton).toBeInTheDocument(); +}); test("Stop button shows when code is running", () => { - const initialState = { - editor: { - codeRunTriggered: true, - codeRunStopped: false - } - } - const store = mockStore(initialState) - render(<Provider store={store}><RunnerControls /></Provider>); - const stopButton = screen.queryByText('runButton.stop'); - expect(stopButton).toBeInTheDocument() -}) + const initialState = { + editor: { + codeRunTriggered: true, + codeRunStopped: false, + }, + }; + const store = mockStore(initialState); + render( + <Provider store={store}> + <RunnerControls /> + </Provider>, + ); + const stopButton = screen.queryByText("runButton.stop"); + expect(stopButton).toBeInTheDocument(); +}); diff --git a/src/components/RunButton/StopButton.js b/src/components/RunButton/StopButton.js index e3be6a05b..fbd55b157 100644 --- a/src/components/RunButton/StopButton.js +++ b/src/components/RunButton/StopButton.js @@ -1,36 +1,43 @@ -import Button from '../Button/Button' -import React, { useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux' -import { stopCodeRun, stopDraw } from '../Editor/EditorSlice' -import { useTranslation } from 'react-i18next' +import Button from "../Button/Button"; +import React, { useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { stopCodeRun, stopDraw } from "../Editor/EditorSlice"; +import { useTranslation } from "react-i18next"; const StopButton = (props) => { - const codeRunStopped = useSelector((state) => state.editor.codeRunStopped); - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered); + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); const dispatch = useDispatch(); - const { t } = useTranslation() + const { t } = useTranslation(); const onClickStop = () => { if (codeRunTriggered) { dispatch(stopCodeRun()); } dispatch(stopDraw()); - } + }; - const stop = <Button className='btn--primary btn--stop' onClickHandler={onClickStop} {...props} /> - const [button, setButton] = useState(stop) + const stop = ( + <Button + className="btn--primary btn--stop" + onClickHandler={onClickStop} + {...props} + /> + ); + const [button, setButton] = useState(stop); useEffect(() => { if (codeRunStopped) { - const stopping = <Button buttonText={t('runButton.stopping')} disabled /> - setTimeout(() => { setButton(stopping) }, 100); + const stopping = <Button buttonText={t("runButton.stopping")} disabled />; + setTimeout(() => { + setButton(stopping); + }, 100); } }, [codeRunStopped, t]); - return ( - button - ) + return button; }; export default StopButton; diff --git a/src/components/RunButton/StopButton.test.js b/src/components/RunButton/StopButton.test.js index c2c4e2630..32dc00128 100644 --- a/src/components/RunButton/StopButton.test.js +++ b/src/components/RunButton/StopButton.test.js @@ -1,49 +1,51 @@ -import React from "react" -import { act, render, fireEvent } from "@testing-library/react" -import { Provider } from 'react-redux' -import StopButton from "./StopButton" -import store from '../../app/store' -import { codeRunHandled, triggerCodeRun } from '../Editor/EditorSlice' +import React from "react"; +import { act, render, fireEvent } from "@testing-library/react"; +import { Provider } from "react-redux"; +import StopButton from "./StopButton"; +import store from "../../app/store"; +import { codeRunHandled, triggerCodeRun } from "../Editor/EditorSlice"; beforeEach(() => { - jest.useFakeTimers() -}) + jest.useFakeTimers(); +}); afterEach(() => { - jest.useRealTimers() -}) + jest.useRealTimers(); +}); test("Clicking stop button sets codeRunStopped to true", () => { - store.dispatch(codeRunHandled()) - store.dispatch(triggerCodeRun()) + store.dispatch(codeRunHandled()); + store.dispatch(triggerCodeRun()); const component = render( <Provider store={store}> - <StopButton buttonText="Stop Code" /> - </Provider> - ) + <StopButton buttonText="Stop Code" /> + </Provider>, + ); - const stopButton = component.getByRole('button') - fireEvent.click(stopButton) + const stopButton = component.getByRole("button"); + fireEvent.click(stopButton); - expect(store.getState().editor.codeRunStopped).toEqual(true) -}) + expect(store.getState().editor.codeRunStopped).toEqual(true); +}); test("Clicking stop button changes it to 'Stopping...' after a time out", () => { - store.dispatch(codeRunHandled()) - store.dispatch(triggerCodeRun()) + store.dispatch(codeRunHandled()); + store.dispatch(triggerCodeRun()); const component = render( <Provider store={store}> <StopButton buttonText="Stop Code" /> - </Provider> - ) - const stopButton = component.getByRole('button') - expect(stopButton.textContent).toEqual("Stop Code") - - fireEvent.click(stopButton) - expect(stopButton.textContent).toEqual("Stop Code") - - act(() => { jest.runAllTimers(); } ) - expect(stopButton.textContent).toEqual("runButton.stopping") -}) + </Provider>, + ); + const stopButton = component.getByRole("button"); + expect(stopButton.textContent).toEqual("Stop Code"); + + fireEvent.click(stopButton); + expect(stopButton.textContent).toEqual("Stop Code"); + + act(() => { + jest.runAllTimers(); + }); + expect(stopButton.textContent).toEqual("runButton.stopping"); +}); diff --git a/src/components/SilentRenew.js b/src/components/SilentRenew.js index a9d2ef5a8..3f3c14de2 100644 --- a/src/components/SilentRenew.js +++ b/src/components/SilentRenew.js @@ -1,15 +1,15 @@ -import { useEffect } from 'react'; +import { useEffect } from "react"; import { connect } from "react-redux"; import { processSilentRenew } from "redux-oidc"; const SilentRenew = () => { useEffect(() => { - console.log('*************************************'); - console.log('silently renewing'); - processSilentRenew() + console.log("*************************************"); + console.log("silently renewing"); + processSilentRenew(); }, []); - return (null); -} + return null; +}; export default connect()(SilentRenew); diff --git a/src/components/ThemeToggle/ThemeToggle.js b/src/components/ThemeToggle/ThemeToggle.js index 4f80b608f..ab1b0820e 100644 --- a/src/components/ThemeToggle/ThemeToggle.js +++ b/src/components/ThemeToggle/ThemeToggle.js @@ -1,42 +1,59 @@ -import React from 'react'; -import { useCookies } from 'react-cookie'; -import { useTranslation } from 'react-i18next'; +import React from "react"; +import { useCookies } from "react-cookie"; +import { useTranslation } from "react-i18next"; -import './ThemeToggle.scss' -import { MoonIcon, SunIcon } from '../../Icons'; +import "./ThemeToggle.scss"; +import { MoonIcon, SunIcon } from "../../Icons"; -const COOKIE_PATHS = ['/', '/projects', '/python'] +const COOKIE_PATHS = ["/", "/projects", "/python"]; const ThemeToggle = () => { - const [ cookies, setCookie, removeCookie ] = useCookies(['theme']) - const isDarkMode = cookies.theme==="dark" || (!cookies.theme && window.matchMedia("(prefers-color-scheme:dark)").matches) - const { t } = useTranslation() + const [cookies, setCookie, removeCookie] = useCookies(["theme"]); + const isDarkMode = + cookies.theme === "dark" || + (!cookies.theme && + window.matchMedia("(prefers-color-scheme:dark)").matches); + const { t } = useTranslation(); const setTheme = (theme) => { if (cookies.theme) { COOKIE_PATHS.forEach((path) => { - removeCookie('theme', {path}) - }) + removeCookie("theme", { path }); + }); } - setCookie('theme', theme, { path: '/' }) - } + setCookie("theme", theme, { path: "/" }); + }; return ( - <div className='theme-toggle'> - <div className='theme-btn theme-btn--light' onClick={() => setTheme('light')}> - <button className={`theme-btn__icon theme-btn__icon--light ${!isDarkMode ? 'theme-btn__icon--active' : null}`}> + <div className="theme-toggle"> + <div + className="theme-btn theme-btn--light" + onClick={() => setTheme("light")} + > + <button + className={`theme-btn__icon theme-btn__icon--light ${ + !isDarkMode ? "theme-btn__icon--active" : null + }`} + > <SunIcon /> </button> - <p>{t('header.settingsMenu.themeOptions.light')}</p> + <p>{t("header.settingsMenu.themeOptions.light")}</p> </div> - <div className='theme-btn theme-btn--dark' onClick={() => setTheme('dark')}> - <button className={`theme-btn__icon theme-btn__icon--dark ${isDarkMode ? 'theme-btn__icon--active' : null}`}> + <div + className="theme-btn theme-btn--dark" + onClick={() => setTheme("dark")} + > + <button + className={`theme-btn__icon theme-btn__icon--dark ${ + isDarkMode ? "theme-btn__icon--active" : null + }`} + > <MoonIcon /> </button> - <p>{t('header.settingsMenu.themeOptions.dark')}</p> + <p>{t("header.settingsMenu.themeOptions.dark")}</p> </div> </div> - ) -} + ); +}; -export default ThemeToggle +export default ThemeToggle; diff --git a/src/components/ThemeToggle/ThemeToggle.test.js b/src/components/ThemeToggle/ThemeToggle.test.js index 231b07264..7500624dc 100644 --- a/src/components/ThemeToggle/ThemeToggle.test.js +++ b/src/components/ThemeToggle/ThemeToggle.test.js @@ -1,7 +1,7 @@ import React from "react"; -import { act, render, fireEvent } from "@testing-library/react" +import { act, render, fireEvent } from "@testing-library/react"; import ThemeToggle from "./ThemeToggle"; -import { Cookies, CookiesProvider } from 'react-cookie'; +import { Cookies, CookiesProvider } from "react-cookie"; describe("When default theme is light mode and cookie unset", () => { let cookies; @@ -17,29 +17,31 @@ describe("When default theme is light mode and cookie unset", () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), - }) + }); cookies = new Cookies(); toggleContainer = render( <CookiesProvider cookies={cookies}> <ThemeToggle /> - </CookiesProvider> - ) - }) + </CookiesProvider>, + ); + }); - test('Cookie remains unset after render', () => { - expect(cookies.cookies.theme).toBeUndefined() - }) + test("Cookie remains unset after render", () => { + expect(cookies.cookies.theme).toBeUndefined(); + }); - test('Sets cookie to dark when button clicked', async () => { - const button = toggleContainer.getByText("header.settingsMenu.themeOptions.dark").parentElement - fireEvent.click(button) - expect(cookies.cookies.theme).toBe("dark") - }) + test("Sets cookie to dark when button clicked", async () => { + const button = toggleContainer.getByText( + "header.settingsMenu.themeOptions.dark", + ).parentElement; + fireEvent.click(button); + expect(cookies.cookies.theme).toBe("dark"); + }); afterEach(() => { - cookies.remove("theme") - }) -}) + cookies.remove("theme"); + }); +}); describe("When default theme is dark mode and cookie unset", () => { let cookies; @@ -55,56 +57,62 @@ describe("When default theme is dark mode and cookie unset", () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), - }) + }); cookies = new Cookies(); toggleContainer = render( <CookiesProvider cookies={cookies}> <ThemeToggle /> - </CookiesProvider> - ) - }) + </CookiesProvider>, + ); + }); - test('Cookie remains unset after render', () => { - expect(cookies.cookies.theme).toBeUndefined() - }) + test("Cookie remains unset after render", () => { + expect(cookies.cookies.theme).toBeUndefined(); + }); - test('Sets cookie to light when button clicked', async () => { - const button = toggleContainer.getByText("header.settingsMenu.themeOptions.light").parentElement - fireEvent.click(button) - expect(cookies.cookies.theme).toBe("light") - }) + test("Sets cookie to light when button clicked", async () => { + const button = toggleContainer.getByText( + "header.settingsMenu.themeOptions.light", + ).parentElement; + fireEvent.click(button); + expect(cookies.cookies.theme).toBe("light"); + }); afterEach(() => { - cookies.remove("theme") - }) -}) + cookies.remove("theme"); + }); +}); -test('Cookie set to dark intially changes to light when button clicked', () => { +test("Cookie set to dark intially changes to light when button clicked", () => { var cookies = new Cookies(); - cookies.set("theme", "dark") + cookies.set("theme", "dark"); const toggleContainer = render( <CookiesProvider cookies={cookies}> <ThemeToggle /> - </CookiesProvider> - ) - const button = toggleContainer.getByText("header.settingsMenu.themeOptions.light").parentElement + </CookiesProvider>, + ); + const button = toggleContainer.getByText( + "header.settingsMenu.themeOptions.light", + ).parentElement; act(() => { - fireEvent.click(button) - }) - expect(cookies.cookies.theme).toBe("light") -}) + fireEvent.click(button); + }); + expect(cookies.cookies.theme).toBe("light"); +}); -test('Cookie set to light intially changes to dark when button clicked', () => { +test("Cookie set to light intially changes to dark when button clicked", () => { var cookies = new Cookies(); - cookies.set("theme", "light") + cookies.set("theme", "light"); var toggleContainer = render( <CookiesProvider cookies={cookies}> <ThemeToggle /> - </CookiesProvider> - ) - const button = toggleContainer.getByText("header.settingsMenu.themeOptions.dark").parentElement + </CookiesProvider>, + ); + const button = toggleContainer.getByText( + "header.settingsMenu.themeOptions.dark", + ).parentElement; act(() => { - fireEvent.click(button) - }) - expect(cookies.cookies.theme).toBe("dark") -}) + fireEvent.click(button); + }); + expect(cookies.cookies.theme).toBe("dark"); +}); diff --git a/src/components/WebComponent/Project/WebComponentProject.js b/src/components/WebComponent/Project/WebComponentProject.js index d397e0500..eb421e1c2 100644 --- a/src/components/WebComponent/Project/WebComponentProject.js +++ b/src/components/WebComponent/Project/WebComponentProject.js @@ -1,39 +1,42 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux' -import { useCookies } from 'react-cookie'; -import Style from 'style-it'; -import internalStyles from '../InternalStyles.scss'; -import externalStyles from '../ExternalStyles.scss'; +import React, { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useCookies } from "react-cookie"; +import Style from "style-it"; +import internalStyles from "../InternalStyles.scss"; +import externalStyles from "../ExternalStyles.scss"; -import Project from '../../Editor/Project/Project'; -import { defaultMZCriteria } from '../../AstroPiModel/DefaultMZCriteria' -import Sk from 'skulpt'; -import store from '../../../app/store'; -import { setIsSplitView } from '../../Editor/EditorSlice'; +import Project from "../../Editor/Project/Project"; +import { defaultMZCriteria } from "../../AstroPiModel/DefaultMZCriteria"; +import Sk from "skulpt"; +import store from "../../../app/store"; +import { setIsSplitView } from "../../Editor/EditorSlice"; const WebComponentProject = () => { const project = useSelector((state) => state.editor.project); - const codeRunTriggered = useSelector((state) => state.editor.codeRunTriggered) - const [cookies] = useCookies(['theme', 'fontSize']) - const defaultTheme = window.matchMedia("(prefers-color-scheme:dark)").matches ? "dark" : "light" + const codeRunTriggered = useSelector( + (state) => state.editor.codeRunTriggered, + ); + const [cookies] = useCookies(["theme", "fontSize"]); + const defaultTheme = window.matchMedia("(prefers-color-scheme:dark)").matches + ? "dark" + : "light"; const [timeoutId, setTimeoutId] = React.useState(null); - const webComponent = document.querySelector('editor-wc') + const webComponent = document.querySelector("editor-wc"); const [codeHasRun, setCodeHasRun] = React.useState(false); - const dispatch = useDispatch() - dispatch(setIsSplitView(false)) + const dispatch = useDispatch(); + dispatch(setIsSplitView(false)); useEffect(() => { - setCodeHasRun(false) - if(timeoutId) clearTimeout(timeoutId); - const id = setTimeout( - function() { - const customEvent = new CustomEvent("codeChanged", { - bubbles: true, - cancelable: false, - composed: true - }); - webComponent.dispatchEvent(customEvent) - }, 2000); + setCodeHasRun(false); + if (timeoutId) clearTimeout(timeoutId); + const id = setTimeout(function () { + const customEvent = new CustomEvent("codeChanged", { + bubbles: true, + cancelable: false, + composed: true, + }); + webComponent.dispatchEvent(customEvent); + }, 2000); setTimeoutId(id); }, [project]); @@ -42,38 +45,44 @@ const WebComponentProject = () => { const runStartedEvent = new CustomEvent("runStarted", { bubbles: true, cancelable: false, - composed: true + composed: true, }); - webComponent.dispatchEvent(runStartedEvent) - setCodeHasRun(true) + webComponent.dispatchEvent(runStartedEvent); + setCodeHasRun(true); } else if (codeHasRun) { const state = store.getState(); - const mz_criteria = Sk.sense_hat ? Sk.sense_hat.mz_criteria : {...defaultMZCriteria} + const mz_criteria = Sk.sense_hat + ? Sk.sense_hat.mz_criteria + : { ...defaultMZCriteria }; const runCompletedEvent = new CustomEvent("runCompleted", { bubbles: true, cancelable: false, composed: true, detail: { isErrorFree: state.editor.error === "", - ...mz_criteria - } + ...mz_criteria, + }, }); - webComponent.dispatchEvent(runCompletedEvent) + webComponent.dispatchEvent(runCompletedEvent); } - - }, [codeRunTriggered] ) + }, [codeRunTriggered]); return ( <> <style>{externalStyles.toString()}</style> <Style> {internalStyles} - <div id='wc' className = {`--${cookies.theme || defaultTheme} font-size-${cookies.fontSize || 'small'}`}> - <Project forWebComponent={true}/> + <div + id="wc" + className={`--${cookies.theme || defaultTheme} font-size-${ + cookies.fontSize || "small" + }`} + > + <Project forWebComponent={true} /> </div> </Style> </> ); -} +}; -export default WebComponentProject +export default WebComponentProject; diff --git a/src/components/WebComponent/WebComponentLoader/WebComponentLoader.js b/src/components/WebComponent/WebComponentLoader/WebComponentLoader.js index a35efdc71..14a17ec27 100644 --- a/src/components/WebComponent/WebComponentLoader/WebComponentLoader.js +++ b/src/components/WebComponent/WebComponentLoader/WebComponentLoader.js @@ -1,31 +1,31 @@ -import React, { useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux' -import { setProject, setSenseHatAlwaysEnabled } from '../../Editor/EditorSlice'; -import WebComponentProject from '../Project/WebComponentProject'; +import React, { useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { setProject, setSenseHatAlwaysEnabled } from "../../Editor/EditorSlice"; +import WebComponentProject from "../Project/WebComponentProject"; const ProjectComponentLoader = (props) => { const loading = useSelector((state) => state.editor.loading); const { code, sense_hat_always_enabled } = props; - const dispatch = useDispatch() + const dispatch = useDispatch(); useEffect(() => { const proj = { - type: 'python', - components: [{ name: 'main', extension: 'py', content: code }] - } - dispatch(setSenseHatAlwaysEnabled(typeof sense_hat_always_enabled !== 'undefined')) - dispatch(setProject(proj)) + type: "python", + components: [{ name: "main", extension: "py", content: code }], + }; + dispatch( + setSenseHatAlwaysEnabled(typeof sense_hat_always_enabled !== "undefined"), + ); + dispatch(setProject(proj)); }, [code, sense_hat_always_enabled, dispatch]); - - - return loading === 'success' ? ( + return loading === "success" ? ( <> <WebComponentProject /> </> ) : ( <> - <p>Loading</p> + <p>Loading</p> </> ); }; diff --git a/src/components/WebComponent/WebComponentSlice.js b/src/components/WebComponent/WebComponentSlice.js index e33fb1190..ef8934aad 100644 --- a/src/components/WebComponent/WebComponentSlice.js +++ b/src/components/WebComponent/WebComponentSlice.js @@ -1,20 +1,18 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice } from "@reduxjs/toolkit"; export const WebComponentSlice = createSlice({ - name: 'component', + name: "component", initialState: { - project: {} + project: {}, }, reducers: { setProject: (state, action) => { state.project = action.payload; - } - } -}) + }, + }, +}); -export const { - setProject, -} = WebComponentSlice.actions +export const { setProject } = WebComponentSlice.actions; -export default WebComponentSlice.reducer +export default WebComponentSlice.reducer; diff --git a/src/hooks/useUserFont.js b/src/hooks/useUserFont.js index 2ef35917f..30fcadd81 100644 --- a/src/hooks/useUserFont.js +++ b/src/hooks/useUserFont.js @@ -1,10 +1,10 @@ -import { useCookies } from 'react-cookie'; +import { useCookies } from "react-cookie"; -const fontScaleFactors = {'small': 1, 'medium': 1.44, 'large': 2.074} +const fontScaleFactors = { small: 1, medium: 1.44, large: 2.074 }; export const useUserFont = (defaultScale = 1) => { - const [cookies] = useCookies(['fontSize']) - const scale = fontScaleFactors[cookies.fontSize] || defaultScale + const [cookies] = useCookies(["fontSize"]); + const scale = fontScaleFactors[cookies.fontSize] || defaultScale; - return scale -} \ No newline at end of file + return scale; +}; diff --git a/src/i18n.js b/src/i18n.js index 1fcd4fe68..ed52f6867 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -155,10 +155,11 @@ i18n newFileModal: { cancel: "Cancel", heading: "Add a new file to your project", - helpText: "Remember to add the file extension at the end of your file name, for example, {{examples}}", - helpTextExample:{ + helpText: + "Remember to add the file extension at the end of your file name, for example, {{examples}}", + helpTextExample: { html: "'file.html' or 'file.css'", - python: "'file.py'" + python: "'file.py'", }, inputLabel: "Name your file", addFile: "Add file", @@ -236,7 +237,7 @@ i18n "Log in to your Raspberry Pi account to save your work, and you'll be able to access and edit your project whenever you need to.", }, modals: { - close: 'Close' + close: "Close", }, notifications: { close: "close", @@ -339,7 +340,7 @@ i18n }, updated: "Edited", python_type: "Python", - html_type: "HTML" + html_type: "HTML", }, runButton: { run: "Run", @@ -347,7 +348,7 @@ i18n stopping: "Stopping...", }, runners: { - HtmlOutput: 'HTML Output Preview' + HtmlOutput: "HTML Output Preview", }, sideMenu: { collapse: "Collapse file pane", diff --git a/src/index.js b/src/index.js index e7075b02b..080c13c23 100644 --- a/src/index.js +++ b/src/index.js @@ -1,22 +1,29 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; +import React from "react"; +import { createRoot } from "react-dom/client"; -import { SentryLink } from 'apollo-link-sentry'; +import { SentryLink } from "apollo-link-sentry"; -import './index.css'; -import './sentry'; -import App from './App'; -import './i18n'; -import { ApolloLink, ApolloProvider, ApolloClient, createHttpLink } from '@apollo/client'; -import { setContext } from '@apollo/client/link/context'; -import { OidcProvider } from 'redux-oidc'; -import { Provider } from 'react-redux'; -import store from './app/store'; -import userManager from './utils/userManager'; -import apolloCache from './utils/apolloCache'; -import { CookiesProvider } from 'react-cookie'; +import "./index.css"; +import "./sentry"; +import App from "./App"; +import "./i18n"; +import { + ApolloLink, + ApolloProvider, + ApolloClient, + createHttpLink, +} from "@apollo/client"; +import { setContext } from "@apollo/client/link/context"; +import { OidcProvider } from "redux-oidc"; +import { Provider } from "react-redux"; +import store from "./app/store"; +import userManager from "./utils/userManager"; +import apolloCache from "./utils/apolloCache"; +import { CookiesProvider } from "react-cookie"; -const apiEndpointLink = createHttpLink({ uri: process.env.REACT_APP_API_ENDPOINT + '/graphql' }); +const apiEndpointLink = createHttpLink({ + uri: process.env.REACT_APP_API_ENDPOINT + "/graphql", +}); const apiAuthLink = setContext((_, { headers }) => { // TODO: ... better way to handle state in Apollo const user = store.getState().auth.user; @@ -26,27 +33,23 @@ const apiAuthLink = setContext((_, { headers }) => { headers: { ...headers, Authorization: user ? user.access_token : "", - } - } + }, + }; }); const client = new ApolloClient({ - link: ApolloLink.from([ - new SentryLink(), - apiAuthLink, - apiEndpointLink - ]), - cache: apolloCache + link: ApolloLink.from([new SentryLink(), apiAuthLink, apiEndpointLink]), + cache: apolloCache, }); -const supportsContainerQueries = 'container' in document.documentElement.style +const supportsContainerQueries = "container" in document.documentElement.style; if (!supportsContainerQueries) { // eslint-disable-next-line no-unused-expressions - import('container-query-polyfill') + import("container-query-polyfill"); } -const div = document.getElementById('root') -const root = createRoot(div) +const div = document.getElementById("root"); +const root = createRoot(div); root.render( <React.StrictMode> <CookiesProvider> @@ -58,7 +61,7 @@ root.render( </Provider> </ApolloProvider> </CookiesProvider> - </React.StrictMode> + </React.StrictMode>, ); // If you want to start measuring performance in your app, pass a function diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js index 5253d3ad9..9ecd33f9c 100644 --- a/src/reportWebVitals.js +++ b/src/reportWebVitals.js @@ -1,6 +1,6 @@ -const reportWebVitals = onPerfEntry => { +const reportWebVitals = (onPerfEntry) => { if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { getCLS(onPerfEntry); getFID(onPerfEntry); getFCP(onPerfEntry); diff --git a/src/sentry.js b/src/sentry.js index 1e92e24dc..04c51570c 100644 --- a/src/sentry.js +++ b/src/sentry.js @@ -1,9 +1,14 @@ -import React from 'react'; -import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router-dom"; +import React from "react"; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, +} from "react-router-dom"; -import * as Sentry from '@sentry/react' -import { BrowserTracing } from '@sentry/tracing'; -import { excludeGraphQLFetch } from 'apollo-link-sentry'; +import * as Sentry from "@sentry/react"; +import { BrowserTracing } from "@sentry/tracing"; +import { excludeGraphQLFetch } from "apollo-link-sentry"; Sentry.init({ dsn: process.env.REACT_APP_SENTRY_DSN, @@ -22,4 +27,4 @@ Sentry.init({ environment: process.env.REACT_APP_SENTRY_ENV, beforeBreadcrumb: excludeGraphQLFetch, tracesSampleRate: 0.8, -}) +}); diff --git a/src/settings.js b/src/settings.js index 41fb3d20d..e20dedc90 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,8 +1,10 @@ -import { createContext } from "react" +import { createContext } from "react"; const SettingsContext = createContext({ - theme: window.matchMedia("(prefers-color-scheme:dark)").matches ? "dark" : "light", - fontSize: 'small' -}) + theme: window.matchMedia("(prefers-color-scheme:dark)").matches + ? "dark" + : "light", + fontSize: "small", +}); -export { SettingsContext } \ No newline at end of file +export { SettingsContext }; diff --git a/src/utils/Geometry.js b/src/utils/Geometry.js index 5529337d8..e5ac51afa 100644 --- a/src/utils/Geometry.js +++ b/src/utils/Geometry.js @@ -1,28 +1,26 @@ var Geometry = { - _Eps: 1e-5 + _Eps: 1e-5, }; - -Geometry.Vector = function(x, y, z) { +Geometry.Vector = function (x, y, z) { this.x = x; this.y = y; this.z = z; -} +}; Geometry.Vector.prototype = { - length: function() { + length: function () { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); }, - normalize: function() { + normalize: function () { var length = this.length(); - if (length <= Geometry._Eps) - return; + if (length <= Geometry._Eps) return; this.x /= length; this.y /= length; this.z /= length; - } -} + }, +}; /** * Transposes a 2-dim Array @@ -31,38 +29,42 @@ Geometry.Vector.prototype = { * * @returns {Array} transposed a */ -Geometry.transpose3x3Matrix = function(a) { - var t = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; - - t[0][0] = a[0][0]; - t[0][1] = a[1][0]; - t[0][2] = a[2][0]; - - t[1][0] = a[0][1]; - t[1][1] = a[1][1]; - t[1][2] = a[2][1]; - - t[2][0] = a[0][2]; - t[2][1] = a[1][2]; - t[2][2] = a[2][2]; - - return t; -} +Geometry.transpose3x3Matrix = function (a) { + var t = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ]; + + t[0][0] = a[0][0]; + t[0][1] = a[1][0]; + t[0][2] = a[2][0]; + + t[1][0] = a[0][1]; + t[1][1] = a[1][1]; + t[1][2] = a[2][1]; + + t[2][0] = a[0][2]; + t[2][1] = a[1][2]; + t[2][2] = a[2][2]; + + return t; +}; /** * Dot multiplication of a 3 by 3 and a 3 by 1 array * * @returns {Array} 3 by 1 array */ -Geometry.dot3x3and3x1 = function(a, b) { - var rs = []; +Geometry.dot3x3and3x1 = function (a, b) { + var rs = []; - rs[0] = a[0][0]*b[0] + a[0][1] * b[1] + a[0][2] * b[2]; - rs[1] = a[1][0]*b[0] + a[1][1] * b[1] + a[1][2] * b[2]; - rs[2] = a[2][0]*b[0] + a[2][1] * b[1] + a[2][2] * b[2]; + rs[0] = a[0][0] * b[0] + a[0][1] * b[1] + a[0][2] * b[2]; + rs[1] = a[1][0] * b[0] + a[1][1] * b[1] + a[1][2] * b[2]; + rs[2] = a[2][0] * b[0] + a[2][1] * b[1] + a[2][2] * b[2]; - return rs; -} + return rs; +}; /** * Mulitplies each array element in a with the scalar s @@ -72,14 +74,9 @@ Geometry.dot3x3and3x1 = function(a, b) { * * @returns {Array} */ -Geometry.multiplyArrayWithScalar = function(a, s) { - return [ - a[0] * s, - a[1] * s, - a[2] * s - ]; -} - +Geometry.multiplyArrayWithScalar = function (a, s) { + return [a[0] * s, a[1] * s, a[2] * s]; +}; /** * Divides each array element in a by scalar s @@ -89,14 +86,9 @@ Geometry.multiplyArrayWithScalar = function(a, s) { * * @returns {Array} */ -Geometry.divideArrayWithScalar = function(a, s) { - return [ - a[0] / s, - a[1] / s, - a[2] / s - ]; -} - +Geometry.divideArrayWithScalar = function (a, s) { + return [a[0] / s, a[1] / s, a[2] / s]; +}; // Some useful defaults for the orientation calculations Geometry.Defaults = {}; @@ -105,7 +97,10 @@ Geometry.Defaults.X = [1, 0, 0]; // x mask Geometry.Defaults.Y = [0, 1, 0]; // y mask Geometry.Defaults.Z = [0, 0, 1]; // z mask -Geometry.Defaults.NORTH = Geometry.multiplyArrayWithScalar(Geometry.Defaults.X, 0.33); +Geometry.Defaults.NORTH = Geometry.multiplyArrayWithScalar( + Geometry.Defaults.X, + 0.33, +); // Gravity vector Geometry.Defaults.GRAVITY = Geometry.Defaults.Z; @@ -113,10 +108,10 @@ Geometry.Defaults.GRAVITY = Geometry.Defaults.Z; /** * Constrain/clamp a given value to upper and lower limit */ -Geometry.clamp = function(value, min_value, max_value) { - var clampVal = Math.min(max_value, Math.max(min_value, value)) - return clampVal; -} +Geometry.clamp = function (value, min_value, max_value) { + var clampVal = Math.min(max_value, Math.max(min_value, value)); + return clampVal; +}; /** * Converts degrees to radians @@ -125,16 +120,16 @@ Geometry.clamp = function(value, min_value, max_value) { * * @returns {Number|Array} depends on the deg param */ -Geometry.degToRad = function(deg) { +Geometry.degToRad = function (deg) { if (deg instanceof Array) { return [ - deg[0] * Math.PI / 180, - deg[1] * Math.PI / 180, - deg[2] * Math.PI / 180 + (deg[0] * Math.PI) / 180, + (deg[1] * Math.PI) / 180, + (deg[2] * Math.PI) / 180, ]; } - return deg * Math.PI / 180; -} + return (deg * Math.PI) / 180; +}; /** * Converts radians to degrees @@ -143,15 +138,15 @@ Geometry.degToRad = function(deg) { * * @returns {Number|Array} depends on the rad param */ -Geometry.radToDeg = function(rad) { +Geometry.radToDeg = function (rad) { if (rad instanceof Array) { return [ - rad[0] * 180 / Math.PI, - rad[1] * 180 / Math.PI, - rad[2] * 180 / Math.PI + (rad[0] * 180) / Math.PI, + (rad[1] * 180) / Math.PI, + (rad[2] * 180) / Math.PI, ]; } - return rad * 180 / Math.PI; -} + return (rad * 180) / Math.PI; +}; -export {Geometry} +export { Geometry }; diff --git a/src/utils/Notifications.js b/src/utils/Notifications.js index d922da107..57c62f06b 100644 --- a/src/utils/Notifications.js +++ b/src/utils/Notifications.js @@ -5,61 +5,66 @@ import Button from "../components/Button/Button"; const CloseButton = ({ closeToast }) => { return ( - <Button ButtonIcon = {CloseIcon} onClickHandler = {closeToast} title={i18n.t('notifications.close')} label={i18n.t('notifications.close')} /> - ) -} + <Button + ButtonIcon={CloseIcon} + onClickHandler={closeToast} + title={i18n.t("notifications.close")} + label={i18n.t("notifications.close")} + /> + ); +}; const bottomCenterSettings = { position: toast.POSITION.BOTTOM_CENTER, autoClose: 3000, - className: 'toast--bottom-center__message', + className: "toast--bottom-center__message", closeButton: false, - containerId: 'bottom-center', - hideProgressBar: true -} + containerId: "bottom-center", + hideProgressBar: true, +}; const topCenterSettings = { position: toast.POSITION.TOP_CENTER, autoClose: 6000, - className: 'toast--top-center__message', + className: "toast--top-center__message", closeButton: CloseButton, - containerId: 'top-center', - hideProgressBar: true -} + containerId: "top-center", + hideProgressBar: true, +}; export const showSavePrompt = () => { - toast(i18n.t('notifications.savePrompt'), { + toast(i18n.t("notifications.savePrompt"), { ...topCenterSettings, className: `${topCenterSettings.className} toast--info`, - icon: InfoIcon + icon: InfoIcon, }); -} +}; export const showLoginPrompt = () => { - toast(i18n.t('notifications.loginPrompt'), { + toast(i18n.t("notifications.loginPrompt"), { ...topCenterSettings, className: `${topCenterSettings.className} toast--info`, - icon: InfoIcon + icon: InfoIcon, }); -} +}; export const showSavedMessage = () => { - toast(i18n.t('notifications.projectSaved'), { + toast(i18n.t("notifications.projectSaved"), { ...bottomCenterSettings, - icon: TickIcon + icon: TickIcon, }); -} +}; export const showRenamedMessage = () => { - toast(i18n.t('notifications.projectRenamed'), { + toast(i18n.t("notifications.projectRenamed"), { ...bottomCenterSettings, - icon: TickIcon - }) -} + icon: TickIcon, + }); +}; export const showRemixedMessage = () => { - toast(i18n.t('notifications.projectRemixed'), { + toast(i18n.t("notifications.projectRemixed"), { ...bottomCenterSettings, - icon: TickIcon + icon: TickIcon, }); -} +}; diff --git a/src/utils/Notifications.test.js b/src/utils/Notifications.test.js index 2d69c9aea..ffc395d1b 100644 --- a/src/utils/Notifications.test.js +++ b/src/utils/Notifications.test.js @@ -1,27 +1,44 @@ -import { toast } from 'react-toastify' -import { showLoginPrompt, showRemixedMessage, showSavedMessage, showSavePrompt } from "./Notifications"; +import { toast } from "react-toastify"; +import { + showLoginPrompt, + showRemixedMessage, + showSavedMessage, + showSavePrompt, +} from "./Notifications"; -jest.mock('../i18n', () => ({ - t: (string) => string -})) -jest.mock('react-toastify') +jest.mock("../i18n", () => ({ + t: (string) => string, +})); +jest.mock("react-toastify"); -test('Calling showRemixedMessage calls toast with correct string', () => { - showRemixedMessage() - expect(toast).toHaveBeenCalledWith('notifications.projectRemixed', expect.anything()) -}) +test("Calling showRemixedMessage calls toast with correct string", () => { + showRemixedMessage(); + expect(toast).toHaveBeenCalledWith( + "notifications.projectRemixed", + expect.anything(), + ); +}); -test('Calling showSavedMessage calls toast with correct string', () => { - showSavedMessage() - expect(toast).toHaveBeenCalledWith('notifications.projectSaved', expect.anything()) -}) +test("Calling showSavedMessage calls toast with correct string", () => { + showSavedMessage(); + expect(toast).toHaveBeenCalledWith( + "notifications.projectSaved", + expect.anything(), + ); +}); -test('Calling showSavePrompt calls toast with correct string', () => { - showSavePrompt() - expect(toast).toHaveBeenCalledWith('notifications.savePrompt', expect.anything()) -}) +test("Calling showSavePrompt calls toast with correct string", () => { + showSavePrompt(); + expect(toast).toHaveBeenCalledWith( + "notifications.savePrompt", + expect.anything(), + ); +}); -test('Calling showLoginPrompt calls toast with correct string', () => { - showLoginPrompt() - expect(toast).toHaveBeenCalledWith('notifications.loginPrompt', expect.anything()) -}) +test("Calling showLoginPrompt calls toast with correct string", () => { + showLoginPrompt(); + expect(toast).toHaveBeenCalledWith( + "notifications.loginPrompt", + expect.anything(), + ); +}); diff --git a/src/utils/Orientation.js b/src/utils/Orientation.js index 926233ce1..d5cbb7a82 100644 --- a/src/utils/Orientation.js +++ b/src/utils/Orientation.js @@ -1,20 +1,20 @@ -import { Geometry } from './Geometry'; -import Sk from 'skulpt'; +import { Geometry } from "./Geometry"; +import Sk from "skulpt"; -function getTimestamp () { +function getTimestamp() { var time = Date.now(); // millis - var timestamp = time * 1e+3; // milliseconds + var timestamp = time * 1e3; // milliseconds return timestamp; } /** -* Update call for periodically updating our internal sensehat data object. -* -* The UI events and the polling are async and therefore we can "simulate" -* even changes when the user does not rotate. -*/ + * Update call for periodically updating our internal sensehat data object. + * + * The UI events and the polling are async and therefore we can "simulate" + * even changes when the user does not rotate. + */ export function updateRTIMU() { -// Retriev the previous timestamp + // Retriev the previous timestamp var oldTimestamp = Sk.sense_hat.rtimu.timestamp; // Special case, if we call this function the first time and @@ -25,7 +25,7 @@ export function updateRTIMU() { // Get a new timestamp and calc the delta var newTimestamp = getTimestamp(); - var timeDelta = (newTimestamp - oldTimestamp) / 1e+6; + var timeDelta = (newTimestamp - oldTimestamp) / 1e6; // Special case, when the delta is 0, everything gets null // Using a sane interval should avoid this case @@ -39,7 +39,7 @@ export function updateRTIMU() { var oldOrientation = Sk.sense_hat.rtimu.raw_old_orientation; if (oldOrientation === null || oldOrientation === undefined) { - oldOrientation = [0,90,0]; + oldOrientation = [0, 90, 0]; } var newOrientation = Geometry.degToRad(Sk.sense_hat.rtimu.raw_orientation); @@ -48,7 +48,7 @@ export function updateRTIMU() { var _gyro = [ newOrientation[0] - oldOrientation[0], newOrientation[1] - oldOrientation[1], - newOrientation[2] - oldOrientation[2] + newOrientation[2] - oldOrientation[2], ]; // Divide the orientation delta by the time delta @@ -71,8 +71,8 @@ export function updateRTIMU() { var R = [ [c1 * c2, c1 * s2 * s3 - c3 * s1, s1 * s3 + c1 * c3 * s2], [c2 * s1, c1 * c3 + s1 * s2 * s3, c3 * s1 * s2 - c1 * s3], - [-s2, c2 * s3, c2 * c3], - ] + [-s2, c2 * s3, c2 * c3], + ]; // Transposed R matrix var T = Geometry.transpose3x3Matrix(R); @@ -97,17 +97,12 @@ export function updateRTIMU() { Sk.sense_hat.rtimu.accel = [ Geometry.clamp(_accel[0], -8, 8), Geometry.clamp(_accel[1], -8, 8), - Geometry.clamp(_accel[2], -8, 8) + Geometry.clamp(_accel[2], -8, 8), ]; // _gyro = perturb(_gyro, .5); // radians per second - Sk.sense_hat.rtimu.gyro = [ - _gyro[0], - _gyro[1], - _gyro[2], - ]; - + Sk.sense_hat.rtimu.gyro = [_gyro[0], _gyro[1], _gyro[2]]; // _compass = perturb(_compass, .01); // multiply with 100 -> from Gauss to microteslas (µT) @@ -118,24 +113,28 @@ export function updateRTIMU() { ]; } -window.rotatemodel = function(x, y, z){ +window.rotatemodel = function (x, y, z) { window.mod.rotation.x = x; window.mod.rotation.y = y; window.mod.rotation.z = z; -} +}; export function resetModel(event) { event.preventDefault(); - var x = 0 - , y = 0 - , z = 0; - window.rotatemodel(Geometry.degToRad(x), Geometry.degToRad(y), Geometry.degToRad(z)); + var x = 0, + y = 0, + z = 0; + window.rotatemodel( + Geometry.degToRad(x), + Geometry.degToRad(y), + Geometry.degToRad(z), + ); } export function extractRollPitchYaw(x, y, z) { - const roll = ((y * 180 / Math.PI) + 360) % 360 - const pitch = ((x * 180 / Math.PI) + 90 + 360) % 360 - const yaw = ((z * 180 / Math.PI) + 360) % 360 - return [roll, pitch, yaw] + const roll = ((y * 180) / Math.PI + 360) % 360; + const pitch = ((x * 180) / Math.PI + 90 + 360) % 360; + const yaw = ((z * 180) / Math.PI + 360) % 360; + return [roll, pitch, yaw]; } diff --git a/src/utils/ResizableWithHandle.js b/src/utils/ResizableWithHandle.js index c58a3bfaa..2a07f0cff 100644 --- a/src/utils/ResizableWithHandle.js +++ b/src/utils/ResizableWithHandle.js @@ -1,59 +1,92 @@ import React, { useState, useMemo } from "react"; import PropTypes from "prop-types"; -import { Resizable } from 're-resizable'; +import { Resizable } from "re-resizable"; -import './ResizableWithHandle.scss'; +import "./ResizableWithHandle.scss"; const VerticalHandle = () => ( - <svg data-testid="verticalHandle" width="44" height="56" viewBox="0 0 44 56" fill="none" xmlns="http://www.w3.org/2000/svg"> + <svg + data-testid="verticalHandle" + width="44" + height="56" + viewBox="0 0 44 56" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > <rect x="20" width="4" height="56" rx="2" fill="#616575" /> </svg> ); const HorizontalHandle = () => ( - <svg data-testid="horizontalHandle" width="56" height="44" viewBox="0 0 56 44" fill="none" xmlns="http://www.w3.org/2000/svg"> - <rect x="56" y="20" width="4" height="56" rx="2" transform="rotate(90 56 20)" fill="#616575"/> + <svg + data-testid="horizontalHandle" + width="56" + height="44" + viewBox="0 0 56 44" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="56" + y="20" + width="4" + height="56" + rx="2" + transform="rotate(90 56 20)" + fill="#616575" + /> </svg> ); -const ResizableWithHandle = props => { - const {children, defaultWidth, defaultHeight, handleDirection, ...rest} = props; +const ResizableWithHandle = (props) => { + const { children, defaultWidth, defaultHeight, handleDirection, ...rest } = + props; - const [width, setWidth] = useState('auto'); - const [height, setHeight] = useState('auto'); + const [width, setWidth] = useState("auto"); + const [height, setHeight] = useState("auto"); useMemo(() => setWidth(defaultWidth), [defaultWidth]); useMemo(() => setHeight(defaultHeight), [defaultHeight]); - const onResizeStop = (...[,,, d]) => { + const onResizeStop = (...[, , , d]) => { setWidth(width + d.width); setHeight(height + d.height); }; - let handleComponent = ['right', 'left'].includes(handleDirection) ? { [handleDirection]: <VerticalHandle /> } : - (['top', 'bottom'].includes(handleDirection) ? { [handleDirection]: <HorizontalHandle /> } : {}); + let handleComponent = ["right", "left"].includes(handleDirection) + ? { [handleDirection]: <VerticalHandle /> } + : ["top", "bottom"].includes(handleDirection) + ? { [handleDirection]: <HorizontalHandle /> } + : {}; let handleWrapperClass = `resizable-with-handle__handle resizable-with-handle__handle--${handleDirection}`; return ( <Resizable - enable={{ top: false, right: false, bottom: false, left: false, ...{[handleDirection]: true} }} + enable={{ + top: false, + right: false, + bottom: false, + left: false, + ...{ [handleDirection]: true }, + }} handleComponent={handleComponent} handleWrapperClass={handleWrapperClass} onResizeStop={onResizeStop} - size={{width: width, height: height}} + size={{ width: width, height: height }} {...rest} > {children} </Resizable> ); -} +}; ResizableWithHandle.propTypes = { children: PropTypes.object.isRequired, defaultWidth: PropTypes.string, defaultHeight: PropTypes.string, - handleDirection: PropTypes.oneOf(['right', 'left', 'top', 'bottom']).isRequired, + handleDirection: PropTypes.oneOf(["right", "left", "top", "bottom"]) + .isRequired, }; export default ResizableWithHandle; diff --git a/src/utils/ResizableWithHandle.test.js b/src/utils/ResizableWithHandle.test.js index c90c9a3d8..79f6b76bf 100644 --- a/src/utils/ResizableWithHandle.test.js +++ b/src/utils/ResizableWithHandle.test.js @@ -1,23 +1,33 @@ import React from "react"; -import {render, screen} from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import ResizableWithHandle from "./ResizableWithHandle"; -test('renders a horizontal handle', () => { - render(<ResizableWithHandle handleDirection='bottom' />); - expect(screen.getByTestId('horizontalHandle')).toBeTruthy(); +test("renders a horizontal handle", () => { + render(<ResizableWithHandle handleDirection="bottom" />); + expect(screen.getByTestId("horizontalHandle")).toBeTruthy(); }); -test('renders a vertical handle', () => { - render(<ResizableWithHandle handleDirection='right' />); - expect(screen.getByTestId('verticalHandle')).toBeTruthy(); +test("renders a vertical handle", () => { + render(<ResizableWithHandle handleDirection="right" />); + expect(screen.getByTestId("verticalHandle")).toBeTruthy(); }); -test('it does not add an incorrect class to the handle', () => { - const {container} = render(<ResizableWithHandle handleDirection='bottom' />); - expect(container.getElementsByClassName('resizable-with-handle__handle--right').length).toBe(0); +test("it does not add an incorrect class to the handle", () => { + const { container } = render( + <ResizableWithHandle handleDirection="bottom" />, + ); + expect( + container.getElementsByClassName("resizable-with-handle__handle--right") + .length, + ).toBe(0); }); -test('it adds the expected class to the handle', () => { - const {container} = render(<ResizableWithHandle handleDirection='bottom' />); - expect(container.getElementsByClassName('resizable-with-handle__handle--bottom').length).toBe(1); +test("it adds the expected class to the handle", () => { + const { container } = render( + <ResizableWithHandle handleDirection="bottom" />, + ); + expect( + container.getElementsByClassName("resizable-with-handle__handle--bottom") + .length, + ).toBe(1); }); diff --git a/src/utils/ToastCloseButton.js b/src/utils/ToastCloseButton.js index b894033fc..a1c0a9afd 100644 --- a/src/utils/ToastCloseButton.js +++ b/src/utils/ToastCloseButton.js @@ -5,10 +5,11 @@ import { CloseIcon } from "../Icons"; const ToastCloseButton = ({ closeToast }) => { return ( <Button - className='btn btn--tertiary' + className="btn btn--tertiary" onClickHandler={closeToast} - ButtonIcon={() => <CloseIcon scaleFactor={0.75} />}/> - ) -} + ButtonIcon={() => <CloseIcon scaleFactor={0.75} />} + /> + ); +}; -export default ToastCloseButton +export default ToastCloseButton; diff --git a/src/utils/ToastCloseButton.test.js b/src/utils/ToastCloseButton.test.js index 90827811f..88fd9fd48 100644 --- a/src/utils/ToastCloseButton.test.js +++ b/src/utils/ToastCloseButton.test.js @@ -2,18 +2,18 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import ToastCloseButton from "./ToastCloseButton"; -const closeToast = jest.fn() +const closeToast = jest.fn(); beforeEach(() => { - render(<ToastCloseButton closeToast={closeToast}/>) -}) + render(<ToastCloseButton closeToast={closeToast} />); +}); -test('Close button renders', () => { - expect(screen.queryByRole('button')).toBeInTheDocument() -}) +test("Close button renders", () => { + expect(screen.queryByRole("button")).toBeInTheDocument(); +}); -test('Clicking close button calls closeToast function', () => { - const closeButton = screen.queryByRole('button') - fireEvent.click(closeButton) - expect(closeToast).toHaveBeenCalled() -}) +test("Clicking close button calls closeToast function", () => { + const closeButton = screen.queryByRole("button"); + fireEvent.click(closeButton); + expect(closeToast).toHaveBeenCalled(); +}); diff --git a/src/utils/apiCallHandler.js b/src/utils/apiCallHandler.js index fe36f8207..69ef43784 100644 --- a/src/utils/apiCallHandler.js +++ b/src/utils/apiCallHandler.js @@ -1,68 +1,91 @@ -import axios from 'axios'; -import omit from 'lodash/omit' +import axios from "axios"; +import omit from "lodash/omit"; const host = process.env.REACT_APP_API_ENDPOINT; const get = async (url, headers) => { return await axios.get(url, headers); -} +}; const post = async (url, body, headers) => { return await axios.post(url, body, headers); -} +}; const put = async (url, body, headers) => { - return await axios.put(url, body, headers) -} + return await axios.put(url, body, headers); +}; const headers = (accessToken) => { - let headersHash - if (accessToken) { - headersHash = {'Accept': 'application/json', 'Authorization': accessToken} - } else { - headersHash = {'Accept': 'application/json'} - } - return {headers: headersHash} -} + let headersHash; + if (accessToken) { + headersHash = { Accept: "application/json", Authorization: accessToken }; + } else { + headersHash = { Accept: "application/json" }; + } + return { headers: headersHash }; +}; export const createOrUpdateProject = async (projectWithUserId, accessToken) => { - const project = omit(projectWithUserId, ['user_id']) + const project = omit(projectWithUserId, ["user_id"]); if (!project.identifier) { - return await post(`${host}/api/projects`, { project }, headers(accessToken)) - } - else { - return await put(`${host}/api/projects/${project.identifier}`, { project }, headers(accessToken)) + return await post( + `${host}/api/projects`, + { project }, + headers(accessToken), + ); + } else { + return await put( + `${host}/api/projects/${project.identifier}`, + { project }, + headers(accessToken), + ); } -} +}; export const deleteProject = async (identifier, accessToken) => { - return await axios.delete(`${host}/api/projects/${identifier}`, headers(accessToken)); -} + return await axios.delete( + `${host}/api/projects/${identifier}`, + headers(accessToken), + ); +}; export const getImage = async (url) => { - return await get(url, headers()) -} + return await get(url, headers()); +}; export const createRemix = async (project, accessToken) => { - return await post(`${host}/api/projects/${project.identifier}/remix`, { project: project}, headers(accessToken)); -} + return await post( + `${host}/api/projects/${project.identifier}/remix`, + { project: project }, + headers(accessToken), + ); +}; export const readProject = async (projectIdentifier, locale, accessToken) => { - const queryString = locale ? `?locale=${locale}` : "" - return await get(`${host}/api/projects/${projectIdentifier}${queryString}`, headers(accessToken)); -} + const queryString = locale ? `?locale=${locale}` : ""; + return await get( + `${host}/api/projects/${projectIdentifier}${queryString}`, + headers(accessToken), + ); +}; export const readProjectList = async (page, accessToken) => { - return await get(`${host}/api/projects`, {params: {page}, ...headers(accessToken)}); -} + return await get(`${host}/api/projects`, { + params: { page }, + ...headers(accessToken), + }); +}; export const uploadImages = async (projectIdentifier, accessToken, images) => { var formData = new FormData(); - images.forEach(image => { - formData.append('images[]', image, image.name); - }) - - return await post(`${host}/api/projects/${projectIdentifier}/images`, formData, {...headers(accessToken), 'Content-Type': 'multipart/form-data'}) -} + images.forEach((image) => { + formData.append("images[]", image, image.name); + }); + return await post( + `${host}/api/projects/${projectIdentifier}/images`, + formData, + { ...headers(accessToken), "Content-Type": "multipart/form-data" }, + ); +}; diff --git a/src/utils/apiCallHandler.test.js b/src/utils/apiCallHandler.test.js index a13e9a3e4..40a8fa7cb 100644 --- a/src/utils/apiCallHandler.test.js +++ b/src/utils/apiCallHandler.test.js @@ -1,111 +1,173 @@ import axios from "axios"; -import { getImage, createOrUpdateProject, readProject, createRemix, uploadImages, readProjectList } from "./apiCallHandler"; - -jest.mock('axios'); +import { + getImage, + createOrUpdateProject, + readProject, + createRemix, + uploadImages, + readProjectList, +} from "./apiCallHandler"; + +jest.mock("axios"); const host = process.env.REACT_APP_API_ENDPOINT; -const defaultHeaders = {'headers': {'Accept': 'application/json'}} -const accessToken = "39a09671-be55-4847-baf5-8919a0c24a25" -const authHeaders = {'headers': {'Accept': 'application/json', 'Authorization': accessToken}} +const defaultHeaders = { headers: { Accept: "application/json" } }; +const accessToken = "39a09671-be55-4847-baf5-8919a0c24a25"; +const authHeaders = { + headers: { Accept: "application/json", Authorization: accessToken }, +}; describe("Testing project API calls", () => { - test("Creating project", async () => { - const newProject = { project_type: 'python', components: [], name: 'Untitled'} - axios.post.mockImplementationOnce(() => Promise.resolve({ + const newProject = { + project_type: "python", + components: [], + name: "Untitled", + }; + axios.post.mockImplementationOnce(() => + Promise.resolve({ + status: 204, + data: { + project: { + identifier: "new-project-identifier", + ...newProject, + }, + }, + }), + ); + + const data = await createOrUpdateProject(newProject); + expect(axios.post).toHaveBeenCalledWith( + `${host}/api/projects`, + { project: newProject }, + defaultHeaders, + ); + expect(data).toStrictEqual({ status: 204, data: { project: { - identifier: 'new-project-identifier', - ...newProject - } - } - })) - - const data = await createOrUpdateProject(newProject) - expect(axios.post).toHaveBeenCalledWith(`${host}/api/projects`, {project: newProject}, defaultHeaders) - expect(data).toStrictEqual({ - status: 204, - data: { project: { identifier: 'new-project-identifier', project_type: 'python', components: [], name: 'Untitled'}} - }) - }) + identifier: "new-project-identifier", + project_type: "python", + components: [], + name: "Untitled", + }, + }, + }); + }); test("Remixing project", async () => { - const originalProject = { identifier: 'original-hello-project', project_type: 'python'} + const originalProject = { + identifier: "original-hello-project", + project_type: "python", + }; axios.post.mockImplementationOnce(() => { - const remixedProject = {'identifier': 'remixed-hello-project', 'project_type': 'python'} - Promise.resolve({data: remixedProject}) - }) - - await createRemix(originalProject, accessToken) - expect(axios.post).toHaveBeenCalledWith((`${host}/api/projects/${originalProject['identifier']}/remix`), { "project": { "identifier": "original-hello-project", "project_type": "python" } }, authHeaders) - }) + const remixedProject = { + identifier: "remixed-hello-project", + project_type: "python", + }; + Promise.resolve({ data: remixedProject }); + }); + + await createRemix(originalProject, accessToken); + expect(axios.post).toHaveBeenCalledWith( + `${host}/api/projects/${originalProject["identifier"]}/remix`, + { + project: { + identifier: "original-hello-project", + project_type: "python", + }, + }, + authHeaders, + ); + }); test("Updating project", async () => { - const project = {'identifier': 'my-wonderful-project', 'project_type': 'python', 'components': []} - axios.put.mockImplementationOnce(() => Promise.resolve(200)) - - await createOrUpdateProject(project) + const project = { + identifier: "my-wonderful-project", + project_type: "python", + components: [], + }; + axios.put.mockImplementationOnce(() => Promise.resolve(200)); + + await createOrUpdateProject(project); expect(axios.put).toHaveBeenCalledWith( - `${host}/api/projects/${project['identifier']}`, + `${host}/api/projects/${project["identifier"]}`, { project: project }, - defaultHeaders - ) - }) + defaultHeaders, + ); + }); test("Read project with identifier only", async () => { - const projectIdentifier = "hello-world-project" - axios.get.mockImplementationOnce(() => Promise.resolve()) + const projectIdentifier = "hello-world-project"; + axios.get.mockImplementationOnce(() => Promise.resolve()); - await readProject(projectIdentifier) - expect(axios.get).toHaveBeenCalledWith(`${host}/api/projects/${projectIdentifier}`, defaultHeaders) - }) + await readProject(projectIdentifier); + expect(axios.get).toHaveBeenCalledWith( + `${host}/api/projects/${projectIdentifier}`, + defaultHeaders, + ); + }); test("Read project with locale", async () => { - const projectIdentifier = "hello-world-project" - const locale = 'es-LA' - axios.get.mockImplementationOnce(() => Promise.resolve()) + const projectIdentifier = "hello-world-project"; + const locale = "es-LA"; + axios.get.mockImplementationOnce(() => Promise.resolve()); - await readProject(projectIdentifier, locale) - expect(axios.get).toHaveBeenCalledWith(`${host}/api/projects/${projectIdentifier}?locale=${locale}`, defaultHeaders) - }) + await readProject(projectIdentifier, locale); + expect(axios.get).toHaveBeenCalledWith( + `${host}/api/projects/${projectIdentifier}?locale=${locale}`, + defaultHeaders, + ); + }); test("Read project with access token", async () => { - const projectIdentifier = "hello-world-project" - axios.get.mockImplementationOnce(() => Promise.resolve()) + const projectIdentifier = "hello-world-project"; + axios.get.mockImplementationOnce(() => Promise.resolve()); - await readProject(projectIdentifier, null, accessToken) - expect(axios.get).toHaveBeenCalledWith(`${host}/api/projects/${projectIdentifier}`, authHeaders) - }) + await readProject(projectIdentifier, null, accessToken); + expect(axios.get).toHaveBeenCalledWith( + `${host}/api/projects/${projectIdentifier}`, + authHeaders, + ); + }); test("Upload image", async () => { - const projectIdentifier = "my-amazing-project" - const image = new File(['(⌐□_□)'], 'image1.png', {type: 'image/png'}) - axios.post.mockImplementationOnce(() => Promise.resolve({status: 200, url: 'google.drive.com/image1.png'})) + const projectIdentifier = "my-amazing-project"; + const image = new File(["(⌐□_□)"], "image1.png", { type: "image/png" }); + axios.post.mockImplementationOnce(() => + Promise.resolve({ status: 200, url: "google.drive.com/image1.png" }), + ); var formData = new FormData(); - formData.append('images[]', image, image.name) + formData.append("images[]", image, image.name); - await uploadImages(projectIdentifier, accessToken, [image]) - expect(axios.post).toHaveBeenCalledWith(`${host}/api/projects/${projectIdentifier}/images`, formData, {...authHeaders, "Content-Type": "multipart/form-data"}) - }) + await uploadImages(projectIdentifier, accessToken, [image]); + expect(axios.post).toHaveBeenCalledWith( + `${host}/api/projects/${projectIdentifier}/images`, + formData, + { ...authHeaders, "Content-Type": "multipart/form-data" }, + ); + }); test("Get image", async () => { - const image = new File(['(⌐□_□)'], 'image1.png', {type: 'image/png'}) - const imageUrl = 'google.drive.com/image1.png' - - axios.get.mockImplementationOnce(() => Promise.resolve({image: image})) - - await getImage(imageUrl) - expect(axios.get).toHaveBeenCalledWith(imageUrl, defaultHeaders) - }) -}) - -describe('Index page API calls', () => { - test('Loading project list', async () => { - axios.get.mockImplementationOnce(() => Promise.resolve(200)) - const page = 3 - await readProjectList(page, accessToken) - expect(axios.get).toHaveBeenCalledWith(`${host}/api/projects`, {...authHeaders, params: {page}}) - }) -}) + const image = new File(["(⌐□_□)"], "image1.png", { type: "image/png" }); + const imageUrl = "google.drive.com/image1.png"; + + axios.get.mockImplementationOnce(() => Promise.resolve({ image: image })); + + await getImage(imageUrl); + expect(axios.get).toHaveBeenCalledWith(imageUrl, defaultHeaders); + }); +}); + +describe("Index page API calls", () => { + test("Loading project list", async () => { + axios.get.mockImplementationOnce(() => Promise.resolve(200)); + const page = 3; + await readProjectList(page, accessToken); + expect(axios.get).toHaveBeenCalledWith(`${host}/api/projects`, { + ...authHeaders, + params: { page }, + }); + }); +}); diff --git a/src/utils/apolloCache.js b/src/utils/apolloCache.js index 85b2be02c..8c8e89bed 100644 --- a/src/utils/apolloCache.js +++ b/src/utils/apolloCache.js @@ -25,7 +25,7 @@ const apolloCache = new InMemoryCache({ // Find prefix if (startCursor) { const index = existing.edges.findIndex( - (edge) => edge.cursor === startCursor + (edge) => edge.cursor === startCursor, ); prefix = index > -1 ? existing.edges.slice(0, index) : existing.edges; @@ -34,7 +34,7 @@ const apolloCache = new InMemoryCache({ // Find suffix if (endCursor) { const index = existing.edges.findIndex( - (edge) => edge.cursor === endCursor + (edge) => edge.cursor === endCursor, ); suffix = index > -1 ? existing.edges.slice(index + 1) : []; } diff --git a/src/utils/componentNameValidation.js b/src/utils/componentNameValidation.js index 8c7e90294..21416e3f5 100644 --- a/src/utils/componentNameValidation.js +++ b/src/utils/componentNameValidation.js @@ -1,48 +1,62 @@ import { setNameError } from "../components/Editor/EditorSlice"; const allowedExtensions = { - "python": [ - "py", - "csv", - "txt" - ], - "html": [ - "html", - "css" - ] -} + python: ["py", "csv", "txt"], + html: ["html", "css"], +}; const allowedExtensionsString = (projectType, t) => { const extensionsList = allowedExtensions[projectType]; if (extensionsList.length === 1) { - return `'.${extensionsList[0]}'` + return `'.${extensionsList[0]}'`; } else { - return `'.${extensionsList.slice(0,-1).join(`', '.`)}' ${t('filePane.errors.or')} '.${extensionsList[extensionsList.length-1]}'`; + return `'.${extensionsList.slice(0, -1).join(`', '.`)}' ${t( + "filePane.errors.or", + )} '.${extensionsList[extensionsList.length - 1]}'`; } -} +}; const isValidFileName = (fileName, projectType, componentNames) => { - const extension = fileName.split('.').slice(1).join('.') - if (allowedExtensions[projectType].includes(extension) && !componentNames.includes(fileName) && fileName.split(' ').length === 1) { + const extension = fileName.split(".").slice(1).join("."); + if ( + allowedExtensions[projectType].includes(extension) && + !componentNames.includes(fileName) && + fileName.split(" ").length === 1 + ) { return true; } else { return false; } -} +}; -export const validateFileName = (fileName, projectType="python", componentNames, dispatch, t, callback, currentFileName=null) => { - const extension = fileName.split('.').slice(1).join('.'); - if (isValidFileName(fileName, projectType, componentNames) || (currentFileName && fileName === currentFileName)) { - callback() +export const validateFileName = ( + fileName, + projectType = "python", + componentNames, + dispatch, + t, + callback, + currentFileName = null, +) => { + const extension = fileName.split(".").slice(1).join("."); + if ( + isValidFileName(fileName, projectType, componentNames) || + (currentFileName && fileName === currentFileName) + ) { + callback(); } else if (componentNames.includes(fileName)) { - dispatch(setNameError(t('filePane.errors.notUnique'))); - } else if (fileName.split(' ').length > 1) { - dispatch(setNameError(t('filePane.errors.containsSpaces'))) + dispatch(setNameError(t("filePane.errors.notUnique"))); + } else if (fileName.split(" ").length > 1) { + dispatch(setNameError(t("filePane.errors.containsSpaces"))); } else if (!allowedExtensions[projectType].includes(extension)) { - dispatch(setNameError(t('filePane.errors.unsupportedExtension', {allowedExtensions: allowedExtensionsString(projectType, t)}))); + dispatch( + setNameError( + t("filePane.errors.unsupportedExtension", { + allowedExtensions: allowedExtensionsString(projectType, t), + }), + ), + ); } else { - dispatch(setNameError(t('filePane.errors.generalError'))); + dispatch(setNameError(t("filePane.errors.generalError"))); } -} - - +}; diff --git a/src/utils/containerQueries.js b/src/utils/containerQueries.js index a90707b41..457357683 100644 --- a/src/utils/containerQueries.js +++ b/src/utils/containerQueries.js @@ -1,5 +1,5 @@ export const projContainer = { - 'width-larger-than-880': { + "width-larger-than-880": { minWidth: 880, - } + }, }; diff --git a/src/utils/defaultProjects.js b/src/utils/defaultProjects.js index 66c1baa00..6d3243691 100644 --- a/src/utils/defaultProjects.js +++ b/src/utils/defaultProjects.js @@ -1,23 +1,28 @@ import i18n from "../i18n"; export const defaultPythonProject = { - project_type: 'python', - name: i18n.t('project.untitled'), + project_type: "python", + name: i18n.t("project.untitled"), locale: null, - components: [ - { extension: 'py', name: 'main', - content: "", default: true }, - ], - image_list: [] -} + components: [{ extension: "py", name: "main", content: "", default: true }], + image_list: [], +}; export const defaultHtmlProject = { - project_type: 'html', - name: i18n.t('project.untitled'), + project_type: "html", + name: i18n.t("project.untitled"), components: [ - { extension: 'html', name: 'index', - content: "<html>\n <head>\n <link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\">\n </head> <body>\n <h1>Heading</h1>\n <p>Paragraph</p>\n </body>\n</html>" }, - { extension: 'css', name: 'style', content: "h1 {\n color: blue;\n}" }, - { extension: 'css', name: 'test', content: "p {\n background-color: red;\n}" } - ] -} + { + extension: "html", + name: "index", + content: + '<html>\n <head>\n <link rel="stylesheet" type="text/css" href="style.css">\n </head> <body>\n <h1>Heading</h1>\n <p>Paragraph</p>\n </body>\n</html>', + }, + { extension: "css", name: "style", content: "h1 {\n color: blue;\n}" }, + { + extension: "css", + name: "test", + content: "p {\n background-color: red;\n}", + }, + ], +}; diff --git a/src/utils/login.js b/src/utils/login.js index e0e74fb1e..5f4593803 100644 --- a/src/utils/login.js +++ b/src/utils/login.js @@ -1,15 +1,26 @@ -import userManager from "./userManager" +import userManager from "./userManager"; -export const login = ({project, location, triggerSave, accessDeniedData} = {}) => { - window.plausible('Login button') +export const login = ({ + project, + location, + triggerSave, + accessDeniedData, +} = {}) => { + window.plausible("Login button"); if (accessDeniedData) { - localStorage.setItem('location', `/projects/${accessDeniedData.identifier}`) + localStorage.setItem( + "location", + `/projects/${accessDeniedData.identifier}`, + ); } else { - localStorage.setItem('location', location.pathname) - localStorage.setItem(project.identifier || 'project', JSON.stringify(project)) + localStorage.setItem("location", location.pathname); + localStorage.setItem( + project.identifier || "project", + JSON.stringify(project), + ); } if (triggerSave) { - localStorage.setItem('awaitingSave', 'true') + localStorage.setItem("awaitingSave", "true"); } userManager.signinRedirect(); -} +}; diff --git a/src/utils/projectHelpers.js b/src/utils/projectHelpers.js index da202901f..af4c9c1db 100644 --- a/src/utils/projectHelpers.js +++ b/src/utils/projectHelpers.js @@ -1,7 +1,7 @@ export const isOwner = (user, project) => { return ( - user && user.profile && + user && + user.profile && (user.profile.user === project.user_id || !project.identifier) - ) -} - \ No newline at end of file + ); +}; diff --git a/src/utils/projectHelpers.test.js b/src/utils/projectHelpers.test.js index 8647412ad..7603fd813 100644 --- a/src/utils/projectHelpers.test.js +++ b/src/utils/projectHelpers.test.js @@ -1,77 +1,77 @@ -import { isOwner } from './projectHelpers' +import { isOwner } from "./projectHelpers"; -describe('With logged in user', () => { +describe("With logged in user", () => { const user = { profile: { - user: 'cd8a5b3d-f7bb-425e-908f-1386decd6bb1' - } - } + user: "cd8a5b3d-f7bb-425e-908f-1386decd6bb1", + }, + }; - describe('who owns current project', () => { + describe("who owns current project", () => { const project = { - identifier: 'hot-diggity-dog', - user_id: 'cd8a5b3d-f7bb-425e-908f-1386decd6bb1' - } + identifier: "hot-diggity-dog", + user_id: "cd8a5b3d-f7bb-425e-908f-1386decd6bb1", + }; - test('isOwner returns true', () => { - expect(isOwner(user, project)).toBeTruthy() - }) - }) + test("isOwner returns true", () => { + expect(isOwner(user, project)).toBeTruthy(); + }); + }); - describe('who does not own current project', () => { + describe("who does not own current project", () => { const project = { - identifier: 'hot-diggity-dog', - user_id: '14f7d384-9e55-470f-b4cc-961236e1becb' - } + identifier: "hot-diggity-dog", + user_id: "14f7d384-9e55-470f-b4cc-961236e1becb", + }; - test('isOwner returns false', () => { - expect(isOwner(user, project)).toBeFalsy() - }) - }) + test("isOwner returns false", () => { + expect(isOwner(user, project)).toBeFalsy(); + }); + }); - describe('and unsaved project', () => { + describe("and unsaved project", () => { const project = { identifier: undefined, - } + }; - test('isOwner returns true', () => { - expect(isOwner(user, project)).toBeTruthy() - }) - }) -}) + test("isOwner returns true", () => { + expect(isOwner(user, project)).toBeTruthy(); + }); + }); +}); -describe('With no active user', () => { - const user = undefined +describe("With no active user", () => { + const user = undefined; - describe('and public project', () => { + describe("and public project", () => { const project = { - identifier: 'blue-suede-shoes', - user_id: undefined - } + identifier: "blue-suede-shoes", + user_id: undefined, + }; - test('isOwner returns false', () => { - expect(isOwner(user, project)).toBeFalsy() - }) - }) + test("isOwner returns false", () => { + expect(isOwner(user, project)).toBeFalsy(); + }); + }); - describe('and private project', () => { + describe("and private project", () => { const project = { - identifier: 'rock-around-clock', - user_id: 'cee6040f-caf6-4029-b758-98f5ad9c0c7a' - } + identifier: "rock-around-clock", + user_id: "cee6040f-caf6-4029-b758-98f5ad9c0c7a", + }; - test('isOwner returns false', () => { - expect(isOwner(user, project)).toBeFalsy() - }) - }) + test("isOwner returns false", () => { + expect(isOwner(user, project)).toBeFalsy(); + }); + }); - describe('and unsaved project', () => { + describe("and unsaved project", () => { const project = { identifier: undefined, - } + }; - test('isOwner returns false', () => { - expect(isOwner(user, project)).toBeFalsy() - }) - }) -}) + test("isOwner returns false", () => { + expect(isOwner(user, project)).toBeFalsy(); + }); + }); +}); diff --git a/src/web-component.js b/src/web-component.js index d55d596ec..5d1693d75 100644 --- a/src/web-component.js +++ b/src/web-component.js @@ -1,12 +1,12 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import * as ReactDOMClient from 'react-dom/client'; -import * as Sentry from '@sentry/react' -import { BrowserTracing } from '@sentry/tracing'; -import WebComponentLoader from './components/WebComponent/WebComponentLoader/WebComponentLoader'; -import store from './app/store' -import { Provider } from 'react-redux' -import './i18n'; +import React from "react"; +import ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; +import * as Sentry from "@sentry/react"; +import { BrowserTracing } from "@sentry/tracing"; +import WebComponentLoader from "./components/WebComponent/WebComponentLoader/WebComponentLoader"; +import store from "./app/store"; +import { Provider } from "react-redux"; +import "./i18n"; Sentry.init({ dsn: process.env.REACT_APP_SENTRY_DSN, @@ -17,7 +17,7 @@ Sentry.init({ // of transactions for performance monitoring. // We recommend adjusting this value in production tracesSampleRate: 1.0, -}) +}); class WebComponent extends HTMLElement { root; @@ -34,7 +34,7 @@ class WebComponent extends HTMLElement { } static get observedAttributes() { - return ['code', 'sense_hat_always_enabled']; + return ["code", "sense_hat_always_enabled"]; } attributeChangedCallback(name, _oldVal, newVal) { @@ -58,7 +58,7 @@ class WebComponent extends HTMLElement { set menuItems(newValue) { // update properties in the web component via js calls from host app // see public/web-component/index.html - console.log('menu items set') + console.log("menu items set"); this.componentProperties.menuItems = newValue; this.mountReactApp(); @@ -70,21 +70,21 @@ class WebComponent extends HTMLElement { mountReactApp() { if (!this.mountPoint) { - this.mountPoint = document.createElement('div'); + this.mountPoint = document.createElement("div"); this.mountPoint.setAttribute("id", "root"); this.mountPoint.setAttribute("style", "height: 100%"); - this.attachShadow({ mode: 'open' }).appendChild(this.mountPoint); + this.attachShadow({ mode: "open" }).appendChild(this.mountPoint); this.root = ReactDOMClient.createRoot(this.mountPoint); } this.root.render( <React.StrictMode> <Provider store={store}> - <WebComponentLoader { ...this.reactProps() }/> + <WebComponentLoader {...this.reactProps()} /> </Provider> - </React.StrictMode> + </React.StrictMode>, ); } } -window.customElements.define('editor-wc', WebComponent); +window.customElements.define("editor-wc", WebComponent); diff --git a/yarn.lock b/yarn.lock index 063c98039..2c1055b47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5771,6 +5771,11 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" +eslint-config-prettier@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz#bfda738d412adc917fd7b038857110efe98c9348" + integrity sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA== + eslint-config-react-app@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-6.0.0.tgz#ccff9fc8e36b322902844cbd79197982be355a0e" @@ -5852,6 +5857,13 @@ eslint-plugin-jsx-a11y@^6.3.1: object.fromentries "^2.0.6" semver "^6.3.0" +eslint-plugin-prettier@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" + integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ== + dependencies: + prettier-linter-helpers "^1.0.0" + eslint-plugin-react-hooks@^4.2.0: version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" @@ -6271,6 +6283,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-diff@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" + integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== + fast-glob@^2.2.6: version "2.2.7" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d" @@ -11028,6 +11045,18 @@ prepend-http@^1.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha512-PhmXi5XmoyKw1Un4E+opM2KcsJInDvKyuOumcjjw3waw86ZNjHwVUOOWLc4bCzLdcKNaWBH9e99sbWzDQsVaYg== +prettier-linter-helpers@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" + integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + dependencies: + fast-diff "^1.1.2" + +prettier@^2.8.8: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + pretty-bytes@^5.3.0, pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"