diff --git a/.eslintrc.json b/.eslintrc.json index e803e0613..c11f0b86f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,9 +1,23 @@ { - "extends": ["eslint:recommended", "plugin:react/all", "plugin:jest/recommended"], - "ignorePatterns": ["service-worker.js"], - "plugins": [ - "react" + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "ecmaFeatures": { "jsx": true } + }, + "env": { + "es2021": true, + "browser": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/all", + "plugin:jest/recommended", + "react-app", + "react-app/jest" ], + "ignorePatterns": ["service-worker.js"], + "plugins": ["react", "jest"], "rules": { "react/jsx-max-depth": ["warn", { "max": 5 }], "indent": ["warn", 2, { "SwitchCase": 1 }], @@ -13,7 +27,12 @@ "react/no-multi-comp": "off", "react/no-set-state": "off", "react/forbid-component-props": "off", + "react-hooks/rules-of-hooks": "warn", + "testing-library/no-container": "warn", + "testing-library/no-node-access": "warn", "no-unreachable": "off", + "no-lone-blocks": "off", + "no-mixed-operators": "off", "react/no-array-index-key": "warn", "react/no-danger": "warn", "no-unused-vars": "warn", @@ -49,30 +68,5 @@ "react": { "version": "detect" } - }, - "globals": { - "document": false, - "process": false, - "navigator": false, - "console": false, - "fetch": false, - "URL": false, - "window": false, - "setInterval": false, - "clearInterval": false, - "Uint8Array": false, - "ArrayBuffer": false, - "DataView": false, - "setTimeout": false, - "clearTimeout": false, - "Promise": false, - "FileReader": false, - "Blob": false, - "localStorage": false, - "__dirname": false, - "require": false, - "Set": false, - "Event": false - }, - "parser": "babel-eslint" + } } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..de1d012a0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + test: + name: Test + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + + - run: yarn install --immutable --immutable-cache --check-cache + + - run: yarn lint + + - run: yarn test diff --git a/package.json b/package.json index c0c3281ae..c465830ec 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,45 @@ { "name": "esc-configurator", - "version": "0.21.0", + "version": "0.22.0", "private": false, "license": "AGPL-3.0", "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.16.7", "@palmabit/react-cookie-law": "^0.6.2", + "autoprefixer": "^10.4.2", "bluejay-rtttl-parse": "^2.0.2", - "compare-versions": "^3.6.0", - "dateformat": "^4.5.1", - "i18next": "^19.9.0", + "compare-versions": "^4.1.3", + "dateformat": "^5.0.2", + "draft-js": "^0.11.7", + "i18next": "^21.6.7", + "prop-types": "^15.8.1", "rc-slider": "^9.7.2", "react": "^17.0.1", "react-dom": "^17.0.1", "react-gtm-module": "^2.0.11", - "react-highlight-within-textarea": "^1.0.1", + "react-highlight-within-textarea": "^2.1.3", "react-i18next": "^11.8.8", "react-input-range": "^1.3.0", - "react-scripts": "4.0.3", - "react-toastify": "^7.0.3", - "react-tooltip": "=4.2.8", - "scheduler": "0.14.0", + "react-scripts": "^5.0.0", + "react-toastify": "^8.1.0", + "react-tooltip": "^4.2.21", + "scheduler": "^0.20.2", "sleep": "^6.3.0", + "ua-parser-js": "^1.0.2", "web-serial-polyfill": "stylesuxx/web-serial-polyfill#temporary-fix", - "web-vitals": "^0.2.4", - "workbox-background-sync": "^5.1.3", - "workbox-broadcast-update": "^5.1.3", - "workbox-cacheable-response": "^5.1.3", - "workbox-core": "^5.1.3", - "workbox-expiration": "^5.1.3", - "workbox-google-analytics": "^5.1.3", - "workbox-navigation-preload": "^5.1.3", - "workbox-precaching": "^5.1.3", - "workbox-range-requests": "^5.1.3", - "workbox-routing": "^5.1.3", - "workbox-strategies": "^5.1.3", - "workbox-streams": "^5.1.3" + "web-vitals": "^2.1.4", + "workbox-background-sync": "^6.4.2", + "workbox-broadcast-update": "^6.4.2", + "workbox-cacheable-response": "^6.4.2", + "workbox-core": "^6.4.2", + "workbox-expiration": "^6.4.2", + "workbox-google-analytics": "^6.4.2", + "workbox-navigation-preload": "^6.4.2", + "workbox-precaching": "^6.4.2", + "workbox-range-requests": "^6.4.2", + "workbox-routing": "^6.4.2", + "workbox-strategies": "^6.4.2", + "workbox-streams": "^6.4.2" }, "scripts": { "start": "react-scripts start", @@ -66,24 +71,25 @@ ] }, "devDependencies": { + "@babel/core": "^7.16.10", + "@babel/plugin-syntax-flow": "^7.16.7", + "@babel/preset-react": "^7.16.7", + "@testing-library/dom": "^8.11.2", "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.10", - "@typescript-eslint/eslint-plugin": "^4.0.0", - "@typescript-eslint/parser": "^4.0.0", - "babel-eslint": "^10.0.0", + "@testing-library/react": "^12.1.2", + "@testing-library/user-event": "^13.5.0", + "@typescript-eslint/eslint-plugin": "^5.10.0", + "@typescript-eslint/parser": "^5.10.0", "codecov": "^3.8.3", - "eslint": "^7.5.0", - "eslint-config-react-app": "^6.0.0", - "eslint-plugin-flowtype": "^5.2.0", - "eslint-plugin-import": "^2.22.0", - "eslint-plugin-jest": "^24.3.2", - "eslint-plugin-jsx-a11y": "^6.3.1", - "eslint-plugin-react": "^7.20.3", - "eslint-plugin-react-hooks": "^4.0.8", + "eslint": "^8.7.0", + "eslint-config-react-app": "^7.0.0", + "eslint-plugin-jest": "^25.7.0", + "eslint-plugin-react": "^7.28.0", + "postcss": "^8.4.5", "pre-commit": "^1.2.2", "pre-push": "^0.1.1", "sass": "^1.32.8", + "typescript": "^4.5.5", "with-staged": "^1.0.2" }, "jest": { diff --git a/src/Components/App/index.jsx b/src/Components/App/index.jsx index daf21a37f..2c26c6970 100644 --- a/src/Components/App/index.jsx +++ b/src/Components/App/index.jsx @@ -25,6 +25,7 @@ function App({ melodies, msp, onAllMotorSpeed, + onClearLog, onCookieAccept, onSaveLog, onSingleMotorSpeed, @@ -88,6 +89,7 @@ function App({ mspFeatures={msp.features} onAllMotorSpeed={onAllMotorSpeed} onCancelFirmwareSelection={escs.actions.handleCancelFirmwareSelection} + onClearLog={onClearLog} onCommonSettingsUpdate={escs.actions.handleCommonSettingsUpdate} onFirmwareDump={escs.actions.handleFirmwareDump} onFlashUrl={escs.actions.handleFlashUrl} @@ -208,6 +210,7 @@ App.propTypes = { }).isRequired, msp: PropTypes.shape({ features: PropTypes.shape({}).isRequired }).isRequired, onAllMotorSpeed: PropTypes.func.isRequired, + onClearLog: PropTypes.func.isRequired, onCookieAccept: PropTypes.func.isRequired, onSaveLog: PropTypes.func.isRequired, onSingleMotorSpeed: PropTypes.func.isRequired, diff --git a/src/Components/AppSettings/index.jsx b/src/Components/AppSettings/index.jsx index 153f03daa..ef29b6b0f 100644 --- a/src/Components/AppSettings/index.jsx +++ b/src/Components/AppSettings/index.jsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import Checkbox from '../Input/Checkbox'; import Overlay from '../Overlay'; @@ -14,12 +14,12 @@ function AppSettings({ }) { const { t } = useTranslation('settings'); - function handleCheckboxChange(e) { + const handleCheckboxChange = useCallback((e) => { const name = e.target.name; const value = e.target.checked; onUpdate(name, value); - } + }, [onUpdate]); const settingKeys = Object.keys(settings); const settingElements = settingKeys.map((key) => { diff --git a/src/Components/Buttonbar/index.jsx b/src/Components/Buttonbar/index.jsx index 09f64b084..c62c0ee1c 100644 --- a/src/Components/Buttonbar/index.jsx +++ b/src/Components/Buttonbar/index.jsx @@ -7,6 +7,7 @@ import GenericButton from './GenericButton'; import './style.scss'; function Buttonbar({ + onClearLog, onOpenMelodyEditor, onReadSetup, onWriteSetup, @@ -38,6 +39,11 @@ function Buttonbar({ text={t('escButtonSaveLog')} /> + +
{ const newExpanded = !state.expanded; setState({ expanded: newExpanded, title: newExpanded ? t('changelogClose') : t('defaultChangelogTitle'), }); - } + }, [state.expanded]); return (
diff --git a/src/Components/FirmwareSelector/__tests__/index.test.jsx b/src/Components/FirmwareSelector/__tests__/index.test.jsx index 6a1d76cb0..d3e50d4df 100644 --- a/src/Components/FirmwareSelector/__tests__/index.test.jsx +++ b/src/Components/FirmwareSelector/__tests__/index.test.jsx @@ -10,6 +10,15 @@ let FirmwareSelector; jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) })); +const mockJsonResponse = (content) => + new window.Response(content, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Time-Cached': Date.now().toString(), + }, + }); + describe('FirmwareSelector', () => { beforeAll(async () => { /** @@ -54,6 +63,15 @@ describe('FirmwareSelector', () => { }); it('should allow changing firmware options for BLHeli_S', async() => { + const json = `[{ "tag_name": "v0.10", "assets": [{}] }]`; + global.caches = { + open: jest.fn().mockImplementation(() => + new Promise((resolve) => { + resolve({ match: () => new Promise((resolve) => resolve(mockJsonResponse(json))) }); + }) + ), + }; + const configs = { versions: {}, escs: {}, @@ -143,7 +161,7 @@ describe('FirmwareSelector', () => { fireEvent.change(screen.getByRole(/combobox/i, { name: 'Version' }), { target: { - value: 'https://github.com/mathiasvr/bluejay/releases/download/v0.10/{0}_v0.10.hex', + value: 'https://github.com/mathiasvr/bluejay/releases/download/v0.10/', name: 'Version', }, }); @@ -167,6 +185,15 @@ describe('FirmwareSelector', () => { }); it('should allow changing firmware options for AM32', async() => { + const json = `[{ "tag_name": "v1.65", "assets": [{}] }]`; + global.caches = { + open: jest.fn().mockImplementation(() => + new Promise((resolve) => { + resolve({ match: () => new Promise((resolve) => resolve(mockJsonResponse(json))) }); + }) + ), + }; + const configs = { versions: {}, escs: {}, @@ -216,7 +243,7 @@ describe('FirmwareSelector', () => { fireEvent.change(screen.getByRole(/combobox/i, { name: 'Version' }), { target: { - value: 'https://github.com/AlkaMotors/AM32-MultiRotor-ESC-firmware/releases/download/v1.65/{0}_1.65.hex', + value: 'https://github.com/AlkaMotors/AM32-MultiRotor-ESC-firmware/releases/download/v1.65/', name: 'Version', }, }); diff --git a/src/Components/FirmwareSelector/index.jsx b/src/Components/FirmwareSelector/index.jsx index 90755b780..28549e190 100644 --- a/src/Components/FirmwareSelector/index.jsx +++ b/src/Components/FirmwareSelector/index.jsx @@ -1,7 +1,10 @@ import { useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; import React, { - useState, useEffect, useRef, + useCallback, + useState, + useEffect, + useRef, } from 'react'; import { @@ -10,6 +13,7 @@ import { } from '../../utils/helpers/General'; import { blheliSource } from '../../sources'; +import sources from '../../sources'; import LabeledSelect from '../Input/LabeledSelect'; @@ -24,6 +28,7 @@ function FirmwareSelector({ onLocalSubmit, onSubmit, selectedMode, + showUnstable, warning, }) { const { t } = useTranslation('common'); @@ -90,8 +95,11 @@ function FirmwareSelector({ name: layout.name, })); - const versionsSelected = versions[selection.firmware]; - const versionOptions = Object.values(versionsSelected).map((version) => ({ + const versionsSelected = Object.values( + versions[selection.firmware].filter((v) => showUnstable || !v.prerelease) + ); + + const versionOptions = versionsSelected.map((version) => ({ key: version.key, value: version.url, name: version.name, @@ -131,9 +139,9 @@ function FirmwareSelector({ } }, [selection.firmware]); - function clickFile() { + const clickFile = useCallback(() => { file.current.click(); - } + }, [file]); /* // TODO: Not yet implemented - this might only be needed for ATMEL @@ -142,7 +150,7 @@ function FirmwareSelector({ } */ - function handleFirmwareChange(e) { + const handleFirmwareChange = useCallback((e) => { const firmware = e.target.value; setSelection({ @@ -150,59 +158,56 @@ function FirmwareSelector({ url: null, pwm: null, }); - } + }, []); - function handleEscChange(e) { + const handleEscChange = useCallback((e) => { setEscLayout(e.target.value); - } + }, [setEscLayout]); - function handleLocalSubmit(e) { + const handleLocalSubmit = useCallback((e) => { e.preventDefault(); onLocalSubmit(e, force, migrate); - } + }, [onLocalSubmit, force, migrate]); - function handleVersionChange(e) { + const handleVersionChange = useCallback((e) => { const selected = e.target.options.selectedIndex; - const selecteOption = e.target.options[selected]; + const selectedOption = e.target.options[selected]; setSelection({ ...selection, url: e.target.value, - version: selecteOption ? selecteOption.text : 'N/A', + version: selectedOption && options.versions[selected - 1].key, }); - } + }, [options, selection]); - function handleForceChange(e) { + const handleForceChange = useCallback((e) => { setForce(e.target.checked); - } + }, [setForce]); - function handleMigrateChange(e) { + const handleMigrateChange = useCallback((e) => { setMigrate(e.target.checked); - } + }, [setMigrate]); - function handlePwmChange(e) { + const handlePwmChange = useCallback((e) => { setSelection({ ...selection, pwm: e.target.value, }); - } - - function handleSubmit() { - const escsAll = escs[selection.firmware]; - - const format = (str2Format, ...args) => - str2Format.replace(/(\{\d+\})/g, (a) => args[+(a.substr(1, a.length - 2)) || 0] ); - - const name = escsAll[escLayout].fileName || escsAll[escLayout].name.replace(/[\s-]/g, '_').toUpperCase(); - const pwmSuffix = selection.pwm ? '_' + selection.pwm : ''; - const formattedUrl = format( - selection.url, - `${name}${pwmSuffix}`, - mode - ); + }, [selection]); + + const handleSubmit = useCallback(() => { + const source = sources.find((s) => s.getName() === selection.firmware); + const firmwareUrl = source.getFirmwareUrl({ + escKey: escLayout, + version: selection.version, + pwm: selection.pwm, + mode: mode, + url: selection.url, + settings: esc.settings, + }); - onSubmit(formattedUrl, escLayout, selection.firmware, selection.version, selection.pwm, force, migrate); - } + onSubmit(firmwareUrl, escLayout, selection.firmware, selection.version, selection.pwm, force, migrate); + }, [sources, escLayout, selection, mode]); const disableFlashButton = !selection.url || (!selection.pwm && options.frequencies.length > 0); @@ -382,6 +387,7 @@ FirmwareSelector.propTypes = { onLocalSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, selectedMode: PropTypes.string, + showUnstable: PropTypes.bool.isRequired, warning: PropTypes.string, }; diff --git a/src/Components/Flash/CommonSettings/index.jsx b/src/Components/Flash/CommonSettings/index.jsx index 21a5fd1c3..e58e5f9f5 100644 --- a/src/Components/Flash/CommonSettings/index.jsx +++ b/src/Components/Flash/CommonSettings/index.jsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; import React, { + useCallback, useEffect, useState, } from 'react'; @@ -49,7 +50,7 @@ function CommonSettings({ } }, [settings]); - function handleCheckboxChange(e) { + const handleCheckboxChange = useCallback((e) => { const newSettings = { ...availableSettings }; const { name, @@ -57,9 +58,9 @@ function CommonSettings({ } = e.target; newSettings[name] = checked ? 1 : 0; setSettings(newSettings); - } + }, [availableSettings]); - function handleSelectChange(e) { + const handleSelectChange = useCallback((e) => { const newSettings = { ...availableSettings }; const { name, @@ -67,17 +68,17 @@ function CommonSettings({ } = e.target; newSettings[name] = value; setSettings(newSettings); - } + }, [availableSettings]); - function handleNumberChange(name, value) { + const handleNumberChange = useCallback((name, value) => { const newSettings = { ...availableSettings }; newSettings[name] = value; setSettings(newSettings); - } + }, [availableSettings]); if (!settingsDescriptions) { - const unsupportedNames = ['JESC']; + const unsupportedNames = ['JESC', 'BLHeli_M', 'BLHeli_32']; const version = `${availableSettings.MAIN_REVISION}.${availableSettings.SUB_REVISION}`; let unsupportedText = ( diff --git a/src/Components/Flash/Escs/Esc/SettingsHandler/index.jsx b/src/Components/Flash/Escs/Esc/SettingsHandler/index.jsx index 03e28e115..06e0717d9 100644 --- a/src/Components/Flash/Escs/Esc/SettingsHandler/index.jsx +++ b/src/Components/Flash/Escs/Esc/SettingsHandler/index.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import Settings from './Settings'; @@ -10,29 +10,29 @@ function SettingsHandler({ onUpdate, settings, }) { - function handleCheckboxChange(e) { + const handleCheckboxChange = useCallback((e) => { const { name, checked, } = e.target; settings[name] = checked ? 1 : 0; onUpdate(settings); - } + }, [onUpdate, settings]); - function handleSelectChange(e) { + const handleSelectChange = useCallback((e) => { const { name, value, } = e.target; settings[name] = value; onUpdate(settings); - } + }, [onUpdate, settings]); - function handleNumberChange(name, value) { + const handleNumberChange = useCallback((name, value) => { settings[name] = value; onUpdate(settings); - } + }, [onUpdate, settings]); return ( { onSettingsUpdate(index, settings); - } + }, [onSettingsUpdate, index, settings]); - function updateCommonSettings(settings) { + const updateCommonSettings = useCallback((settings) => { onCommonSettingsUpdate(index, settings); - } + }, [onCommonSettingsUpdate, index, settings]); - function handleFirmwareFlash() { + const handleFirmwareFlash = useCallback(() => { onFlash(index); - } + }, [onFlash, index]); - function handleFirmwareDump() { + const handleFirmwareDump = useCallback(() => { onFirmwareDump(index); - } + }, [onFirmwareDump, index]); return (
diff --git a/src/Components/Home/index.jsx b/src/Components/Home/index.jsx index d98d83bd9..833333979 100644 --- a/src/Components/Home/index.jsx +++ b/src/Components/Home/index.jsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; import React, { + useCallback, useRef, useState, } from 'react'; @@ -19,11 +20,11 @@ function Install() { setShowInstall(true); }); - function handleInstallToHomescreen() { + const handleInstallToHomescreen = useCallback(() => { if(deferredPrompt.current) { deferredPrompt.current.prompt(); } - } + }, [deferredPrompt]); return(
diff --git a/src/Components/Input/LabeledSelect/index.jsx b/src/Components/Input/LabeledSelect/index.jsx index 401cbea88..ed23bcc70 100644 --- a/src/Components/Input/LabeledSelect/index.jsx +++ b/src/Components/Input/LabeledSelect/index.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; function LabeledSelect({ label, @@ -8,7 +8,7 @@ function LabeledSelect({ selected, onChange, }) { - function Select() { + const Select = useCallback(() => { const optionElements = options.map((item) => (