diff --git a/.env.development b/.env.development index e69de29..7652eb4 100644 --- a/.env.development +++ b/.env.development @@ -0,0 +1 @@ +REACT_APP_TRANSMISSION_RPC=http://tr.zj9495.com:9091 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9fb8148..2f78edb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,10 @@ -name: Deploy To GCP +name: Deploy preview environment on: push: branches: - master + pull_request: + branches: [master] jobs: build-and-deploy: name: Deploy @@ -36,9 +38,9 @@ jobs: - uses: appleboy/scp-action@master with: host: tr.zj9495.com - username: zj9495 + username: ubuntu port: 22 - key: ${{ secrets.DEPLOY_SSH_KEY }} + key: ${{ secrets.DEPLOY_PREVIEW_SSH_KEY }} source: "build/*" - target: "/mnt/disks/disk1/transmission/transmission-client" + target: "/home/ubuntu/transmission-client" strip_components: 1 diff --git a/.github/workflows/e2e-test.chrome.yml b/.github/workflows/e2e-test.chrome.yml new file mode 100644 index 0000000..408e63a --- /dev/null +++ b/.github/workflows/e2e-test.chrome.yml @@ -0,0 +1,59 @@ +name: E2E on Chrome + +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + e2e-on-chrome: + runs-on: ubuntu-20.04 + strategy: + # when one test fails, DO NOT cancel the other + # containers, because this will kill Cypress processes + # leaving the Dashboard hanging ... + # https://github.com/cypress-io/github-action/issues/48 + fail-fast: false + matrix: + # run 4 copies of the current job in parallel + containers: [1, 2, 3, 4] + services: + transmission-rpc: + image: linuxserver/transmission:latest + env: + PUID: 1000 + PGID: 1000 + TZ: Europe/London + USER: zj9495 + PASS: zj9495 + ports: + - 9091:9091 + nginx: + image: nginx:latest + env: + NGINX_PORT: 80 + ports: + - 8080:80 + steps: + - name: Checkout + uses: actions/checkout@v1 + # Install NPM dependencies, cache them correctly + # and run all Cypress tests + - name: Cypress run + uses: cypress-io/github-action@v2 + with: + browser: chrome + headless: true + start: npm start + wait-on: "http://localhost:8888" + record: true + parallel: true + env: + # pass the Dashboard record key as an environment variable + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # pass GitHub token to allow accurately detecting a build vs a re-run build + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REACT_APP_TRANSMISSION_RPC: http://localhost:9091 + REACT_APP_RPC_DELAY: 100 diff --git a/.github/workflows/e2e-test.edge.yml b/.github/workflows/e2e-test.edge.yml new file mode 100644 index 0000000..1e5baf3 --- /dev/null +++ b/.github/workflows/e2e-test.edge.yml @@ -0,0 +1,62 @@ +name: E2E on Edge + +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + e2e-on-edge: + runs-on: ubuntu-20.04 + container: + image: cypress/browsers:node14.10.1-edge88 + options: --user 1001 + strategy: + # when one test fails, DO NOT cancel the other + # containers, because this will kill Cypress processes + # leaving the Dashboard hanging ... + # https://github.com/cypress-io/github-action/issues/48 + fail-fast: false + matrix: + # run 4 copies of the current job in parallel + containers: [1, 2, 3, 4] + services: + transmission-rpc: + image: linuxserver/transmission:latest + env: + PUID: 1000 + PGID: 1000 + TZ: Europe/London + USER: zj9495 + PASS: zj9495 + ports: + - 9091:9091 + nginx: + image: nginx:latest + env: + NGINX_PORT: 80 + ports: + - 8080:80 + steps: + - name: Checkout + uses: actions/checkout@v1 + # Install NPM dependencies, cache them correctly + # and run all Cypress tests + - name: Cypress run + uses: cypress-io/github-action@v2 + with: + browser: edge + headless: true + start: npm start + wait-on: "http://localhost:8888" + record: true + parallel: true + env: + # pass the Dashboard record key as an environment variable + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # pass GitHub token to allow accurately detecting a build vs a re-run build + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REACT_APP_TRANSMISSION_RPC: http://transmission-rpc:9091 + REACT_APP_RPC_DELAY: 100 diff --git a/.github/workflows/e2e-test.ff.yml b/.github/workflows/e2e-test.ff.yml new file mode 100644 index 0000000..febf386 --- /dev/null +++ b/.github/workflows/e2e-test.ff.yml @@ -0,0 +1,61 @@ +name: E2E on FireFox + +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + e2e-on-firefox: + runs-on: ubuntu-20.04 + container: + image: cypress/browsers:node14.16.0-chrome90-ff88 + options: --user 1001 + strategy: + # when one test fails, DO NOT cancel the other + # containers, because this will kill Cypress processes + # leaving the Dashboard hanging ... + # https://github.com/cypress-io/github-action/issues/48 + fail-fast: false + matrix: + # run 4 copies of the current job in parallel + containers: [1, 2, 3, 4] + services: + transmission-rpc: + image: linuxserver/transmission:latest + env: + PUID: 1000 + PGID: 1000 + TZ: Europe/London + USER: zj9495 + PASS: zj9495 + ports: + - 9091:9091 + nginx: + image: nginx:latest + env: + NGINX_PORT: 80 + ports: + - 8080:80 + steps: + - name: Checkout + uses: actions/checkout@v1 + # Install NPM dependencies, cache them correctly + # and run all Cypress tests + - name: Cypress run + uses: cypress-io/github-action@v2 + with: + browser: firefox + start: npm start + wait-on: "http://localhost:8888" + record: true + parallel: true + env: + # pass the Dashboard record key as an environment variable + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # pass GitHub token to allow accurately detecting a build vs a re-run build + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REACT_APP_TRANSMISSION_RPC: http://transmission-rpc:9091 + REACT_APP_RPC_DELAY: 100 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml deleted file mode 100644 index cbd7d79..0000000 --- a/.github/workflows/e2e-test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: End-to-end tests - -on: - # Triggers the workflow on push or pull request events but only for the master branch - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - cypress-run: - runs-on: ubuntu-20.04 - steps: - - name: Checkout - uses: actions/checkout@v1 - # Install NPM dependencies, cache them correctly - # and run all Cypress tests - - name: Cypress run - uses: cypress-io/github-action@v2 - with: - start: npm start - wait-on: "http://localhost:8888" - record: true - env: - # pass the Dashboard record key as an environment variable - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - # pass GitHub token to allow accurately detecting a build vs a re-run build - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cypress.env.json b/cypress.env.json new file mode 100644 index 0000000..173ce5b --- /dev/null +++ b/cypress.env.json @@ -0,0 +1,8 @@ +{ + "protocol": "http", + "host": "localhost", + "port": 8888, + "path": "/transmission/web", + "user": "zj9495", + "password": "zj9495" +} \ No newline at end of file diff --git a/cypress/integration/add.spec.js b/cypress/integration/add.spec.js index 6a262b0..b2e48e8 100644 --- a/cypress/integration/add.spec.js +++ b/cypress/integration/add.spec.js @@ -1,6 +1,6 @@ /// -import { removeTestTorrent } from "./common" +import { removeTestTorrent, TEST_URL } from "./common" const DOWNLOAD_DIR = "/downloads/complete" const TORRENT = { @@ -10,7 +10,7 @@ const TORRENT = { context('app', () => { beforeEach(() => { - cy.visit(`http://zj9495:zj9495@localhost:8888/transmission-client`) + cy.visit(TEST_URL) cy.verifyConnected() }) @@ -61,6 +61,8 @@ context('app', () => { cy.contains('Adding...') cy.contains(TORRENT.NAME) cy.contains('Cancel').click() + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(3000); cy.contains(TORRENT.NAME).should('not.exist') }) diff --git a/cypress/integration/app.spec.js b/cypress/integration/app.spec.js index cd003d3..537ca67 100644 --- a/cypress/integration/app.spec.js +++ b/cypress/integration/app.spec.js @@ -1,8 +1,10 @@ /// +import { TEST_URL } from "./common" + context('app', () => { beforeEach(() => { - cy.visit(`http://zj9495:zj9495@localhost:8888/transmission-client`) + cy.visit(TEST_URL) }) it('boot', () => { diff --git a/cypress/integration/common/index.js b/cypress/integration/common/index.js index 54a9d65..7c4b016 100644 --- a/cypress/integration/common/index.js +++ b/cypress/integration/common/index.js @@ -25,4 +25,13 @@ export const removeTestTorrent = () => { cy.get('[data-testid=delete-local-data] [type=checkbox]').check() cy.contains('OK').click() cy.contains(TEST_TORRENT.NAME).should('not.exist') -}; \ No newline at end of file +}; + +export const getUrl = () => { + console.log('TEST_URL: '); + const { env } = Cypress + // result example: protocol://user:password@host:port/path + return `${env('protocol')}://${env('user')}:${env('password')}@${env('host')}:${env('port')}${env('path')}` +} + +export const TEST_URL = getUrl() \ No newline at end of file diff --git a/cypress/integration/i18n.spec.js b/cypress/integration/i18n.spec.js index 30690f2..22de3b1 100644 --- a/cypress/integration/i18n.spec.js +++ b/cypress/integration/i18n.spec.js @@ -1,10 +1,12 @@ /// +import { TEST_URL } from "./common" import langs from "../../src/i18n/lang" +import { STORAGE_KEYS } from "../../src/constants" context('i18n', () => { beforeEach(() => { - cy.visit(`http://zj9495:zj9495@localhost:8888/transmission-client`) + cy.visit(TEST_URL) cy.verifyConnected() }) @@ -122,4 +124,221 @@ context('i18n', () => { cy.get('#torrent-table .MuiDataGrid-colCellTitle').contains(item.totalSize) }) }) + + it('test get the locale from the url', () => { + const TEST_LANGS = [ + { + codes: ["en", "en_US", "en_AU", "en_CA", "en_GB"], + text: "English", + name: langs.en['torrent.fields.name'], + totalSize: langs.en['torrent.fields.totalSize'] + }, + { + codes: ["zh", "zh_CN"], + text: "中文 - 简体", + name: langs.zh_CN['torrent.fields.name'], + totalSize: langs.zh_CN['torrent.fields.totalSize'] + }, + { + codes: ["zh_TW", "zh_HK"], + text: "中文 - 繁体", + name: langs.zh_TW['torrent.fields.name'], + totalSize: langs.zh_TW['torrent.fields.totalSize'] + }, + { + codes: ["de", "de_DE", "de_AT"], + text: "Deutsch", + name: langs.de['torrent.fields.name'], + totalSize: langs.de['torrent.fields.totalSize'] + }, + { + codes: ["es", "es_ES", "es_MX"], + text: "Español", + name: langs.es['torrent.fields.name'], + totalSize: langs.es['torrent.fields.totalSize'] + }, + { + codes: ["fr", "fr_FR", "fr_CA"], + text: "Français", + name: langs.fr['torrent.fields.name'], + totalSize: langs.fr['torrent.fields.totalSize'] + }, + { + codes: ["hu", "hu_HU"], + text: "Magyar", + name: langs.hu['torrent.fields.name'], + totalSize: langs.hu['torrent.fields.totalSize'] + }, + { + codes: ["it", "it_IT", "it_CH"], + text: "Italiano", + name: langs.it['torrent.fields.name'], + totalSize: langs.it['torrent.fields.totalSize'] + }, + { + codes: ["ko", "ko_KR"], + text: "Korean", + name: langs.ko['torrent.fields.name'], + totalSize: langs.ko['torrent.fields.totalSize'] + }, + { + codes: ["nl", "nl_NL", "nl_BE"], + text: "Nederlands", + name: langs.nl['torrent.fields.name'], + totalSize: langs.nl['torrent.fields.totalSize'] + }, + { + codes: ["pt_BR"], + text: "Português - Brasil", + name: langs.pt_BR['torrent.fields.name'], + totalSize: langs.pt_BR['torrent.fields.totalSize'] + }, + { + codes: ["pt_PT"], + text: "Português - Portugal", + name: langs.pt_PT['torrent.fields.name'], + totalSize: langs.pt_PT['torrent.fields.totalSize'] + }, + { + codes: ["ro", "ro_RO"], + text: "Romanian", + name: langs.ro['torrent.fields.name'], + totalSize: langs.ro['torrent.fields.totalSize'] + }, + { + codes: ["ru", "ru_RU"], + text: "Русский", + name: langs.ru['torrent.fields.name'], + totalSize: langs.ru['torrent.fields.totalSize'] + }, + { + codes: ["uk", "uk_UA"], + text: "українська мова", + name: langs.uk['torrent.fields.name'], + totalSize: langs.uk['torrent.fields.totalSize'] + }, + ] + const url = `${TEST_URL}?locale=` + TEST_LANGS.forEach(language => { + language.codes.forEach(locale => { + cy.visit(`${url}${locale}`) + cy.verifyConnected() + cy.get('#selected-language').contains(language.text) + cy.get('#torrent-table .MuiDataGrid-colCellTitle').contains(language.name) + cy.get('#torrent-table .MuiDataGrid-colCellTitle').contains(language.totalSize) + }) + }) + }) + + it('use the browser language as the default language', () => { + const TEST_LANGS = [ + { + codes: ["en_US", "en_AU", "en_CA", "en_GB"], + text: "English", + name: langs.en['torrent.fields.name'], + totalSize: langs.en['torrent.fields.totalSize'] + }, + { + codes: ["zh_CN"], + text: "中文 - 简体", + name: langs.zh_CN['torrent.fields.name'], + totalSize: langs.zh_CN['torrent.fields.totalSize'] + }, + { + codes: ["zh_TW", "zh_HK"], + text: "中文 - 繁体", + name: langs.zh_TW['torrent.fields.name'], + totalSize: langs.zh_TW['torrent.fields.totalSize'] + }, + { + codes: ["de_DE", "de_AT"], + text: "Deutsch", + name: langs.de['torrent.fields.name'], + totalSize: langs.de['torrent.fields.totalSize'] + }, + { + codes: ["es_ES", "es_MX"], + text: "Español", + name: langs.es['torrent.fields.name'], + totalSize: langs.es['torrent.fields.totalSize'] + }, + { + codes: ["fr_FR", "fr_CA"], + text: "Français", + name: langs.fr['torrent.fields.name'], + totalSize: langs.fr['torrent.fields.totalSize'] + }, + { + codes: ["hu_HU"], + text: "Magyar", + name: langs.hu['torrent.fields.name'], + totalSize: langs.hu['torrent.fields.totalSize'] + }, + { + codes: ["it_IT", "it_CH"], + text: "Italiano", + name: langs.it['torrent.fields.name'], + totalSize: langs.it['torrent.fields.totalSize'] + }, + { + codes: ["ko_KR"], + text: "Korean", + name: langs.ko['torrent.fields.name'], + totalSize: langs.ko['torrent.fields.totalSize'] + }, + { + codes: ["nl_NL", "nl_BE"], + text: "Nederlands", + name: langs.nl['torrent.fields.name'], + totalSize: langs.nl['torrent.fields.totalSize'] + }, + { + codes: ["pt_BR"], + text: "Português - Brasil", + name: langs.pt_BR['torrent.fields.name'], + totalSize: langs.pt_BR['torrent.fields.totalSize'] + }, + { + codes: ["pt_PT"], + text: "Português - Portugal", + name: langs.pt_PT['torrent.fields.name'], + totalSize: langs.pt_PT['torrent.fields.totalSize'] + }, + { + codes: ["ro_RO"], + text: "Romanian", + name: langs.ro['torrent.fields.name'], + totalSize: langs.ro['torrent.fields.totalSize'] + }, + { + codes: ["ru_RU"], + text: "Русский", + name: langs.ru['torrent.fields.name'], + totalSize: langs.ru['torrent.fields.totalSize'] + }, + { + codes: ["uk_UA"], + text: "українська мова", + name: langs.uk['torrent.fields.name'], + totalSize: langs.uk['torrent.fields.totalSize'] + }, + ] + TEST_LANGS.forEach(language => { + language.codes.forEach(locale => { + cy.visit(TEST_URL, { + onBeforeLoad: (window) => { + // 'window:before:load:' will be fired before 'onBeforeLoad', so use setTimeout to hack it + setTimeout(() => { + Object.defineProperty(window.navigator, 'language', { value: locale, configurable: true }); + window.localStorage.removeItem(STORAGE_KEYS.LOCALE) + }) + } + }) + cy.verifyConnected() + cy.get('#selected-language').contains(language.text) + cy.get('#torrent-table .MuiDataGrid-colCellTitle').contains(language.name) + cy.get('#torrent-table .MuiDataGrid-colCellTitle').contains(language.totalSize) + }) + }) + }) }) \ No newline at end of file diff --git a/cypress/integration/list.spec.js b/cypress/integration/list.spec.js index d83a44e..cf6a52f 100644 --- a/cypress/integration/list.spec.js +++ b/cypress/integration/list.spec.js @@ -1,20 +1,37 @@ /// -import { removeTestTorrent, addTestTorrent } from "./common" +import { removeTestTorrent, addTestTorrent, TEST_URL } from "./common" import { TEST_TORRENT } from "../fixtures/constants" -context('test torrent list', () => { +context("test torrent list", () => { beforeEach(() => { - cy.visit(`http://zj9495:zj9495@localhost:8888/transmission-client`) + cy.visit(TEST_URL) cy.verifyConnected() - addTestTorrent(false) }) - afterEach(() => { + it("should format ratio if ratio < 0", () => { + addTestTorrent(false) + + cy.contains(TEST_TORRENT.NAME).closest(".MuiDataGrid-row").find("[data-field=uploadRatio]").contains("0") + removeTestTorrent() }) - it('should format ratio if ratio < 0', () => { - cy.contains(TEST_TORRENT.NAME).closest('.MuiDataGrid-row').find('[data-field=uploadRatio]').contains('0') + it("test the default display columns", () => { + const COLUMNS = { + DISPLAY: ["name", "totalSize", "percentDone", "leftUntilDone", "uploadRatio", "status", "seederCount", "leecherCount", "rateDownload", "rateUpload", "addedDate", "downloadDir"], + HIDE: ["completeSize", "uploadedEver", "queuePosition", "trackers", "activityDate", "labels", "doneDate"] + } + + cy.viewport(1920, 1080) + + COLUMNS.DISPLAY.forEach(name => { + // should be display + cy.get(`[data-field=${name}]`).should("be.exist") + }) + COLUMNS.HIDE.forEach(name => { + // should not be display + cy.get(`[data-field=${name}]`).should("not.exist") + }) }) }) \ No newline at end of file diff --git a/cypress/integration/removeTorrents.spec.js b/cypress/integration/removeTorrents.spec.js index 15d360b..08d48f9 100644 --- a/cypress/integration/removeTorrents.spec.js +++ b/cypress/integration/removeTorrents.spec.js @@ -1,11 +1,11 @@ /// -import { addTestTorrent } from "./common" +import { addTestTorrent, TEST_URL } from "./common" import { TEST_TORRENT } from "../fixtures/constants" context('app', () => { beforeEach(() => { - cy.visit(`http://zj9495:zj9495@localhost:8888/transmission-client`) + cy.visit(TEST_URL) cy.verifyConnected() }) @@ -21,4 +21,20 @@ context('app', () => { cy.contains('Successfully removed!') cy.contains(TEST_TORRENT.NAME).should('not.exist') }) + + it('should clear the selection after by multiple delete', () => { + addTestTorrent() + + cy.contains(TEST_TORRENT.NAME).closest('.MuiDataGrid-row').find('input[type=checkbox]').check() + cy.get('.MuiDataGrid-columnsContainer input[type=checkbox]').should('be.checked') + + cy.getByTestId('delete-btn').click() + cy.contains('Remove confirm') + cy.get('[data-testid=delete-local-data] [type=checkbox]').check() + cy.contains('OK').click() + cy.contains('Successfully removed!') + + cy.get('.MuiDataGrid-columnsContainer input[type=checkbox]').should('not.checked') + cy.getByTestId('delete-btn').should('be.disabled') + }) }) \ No newline at end of file diff --git a/cypress/integration/theme.spec.js b/cypress/integration/theme.spec.js new file mode 100644 index 0000000..8c2ea31 --- /dev/null +++ b/cypress/integration/theme.spec.js @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/// + +import { TEST_URL } from "./common" + +context('app', () => { + beforeEach(() => { + cy.visit(TEST_URL) + cy.verifyConnected() + }) + + it('should use the auto theme at first boot', () => { + cy.clearLocalStorage(); + cy.reload(); + cy.getByTestId("theme-toggle-button").find("[data-test-id=auto]").should('be.exist'); + }) + + it('should use the last theme when reloading', () => { + cy.clearLocalStorage(); + cy.reload(); + + cy.getByTestId("theme-toggle-button").click(); + cy.getByTestId("theme-toggle-button").find("[data-test-id=light]").should('be.exist'); + cy.reload(); + cy.getByTestId("theme-toggle-button").find("[data-test-id=light]").should('be.exist'); + + cy.getByTestId("theme-toggle-button").click(); + cy.getByTestId("theme-toggle-button").find("[data-test-id=dark]").should('be.exist'); + cy.reload(); + cy.getByTestId("theme-toggle-button").find("[data-test-id=dark]").should('be.exist'); + }) + + it('should use the system theme on the auto theme', () => { + const DAKR_THEME_SWITCH = { + ENABLED: true, + DISABLED: false + } + + cy.clearLocalStorage(); + cy.visit(TEST_URL, { + onBeforeLoad: (window) => { + cy.stub(window, 'matchMedia') + .withArgs('(prefers-color-scheme: dark)') + .returns({ + matches: DAKR_THEME_SWITCH.ENABLED, + addEventListener: () => {} + }) + } + }) + + cy.getByTestId("theme-toggle-button").find("[data-test-id=auto]").should('be.exist'); + cy.get("body").invoke("css", "background-color").should("eq", "rgb(48, 48, 48)"); + + cy.clearLocalStorage(); + cy.visit(TEST_URL, { + onBeforeLoad: (window) => { + cy.stub(window, 'matchMedia') + .withArgs('(prefers-color-scheme: dark)') + .returns({ + matches: DAKR_THEME_SWITCH.DISABLED, + addEventListener: () => {} + }) + } + }) + + cy.getByTestId("theme-toggle-button").find("[data-test-id=auto]").should('be.exist'); + cy.get("body").invoke("css", "background-color").should("eq", "rgb(250, 250, 250)"); + }) +}) \ No newline at end of file diff --git a/cypress/support/index.js b/cypress/support/index.js index d68db96..cc45fd7 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -18,3 +18,7 @@ import './commands' // Alternatively you can use CommonJS syntax: // require('./commands') + +Cypress.on('window:before:load', window => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }); +}); \ No newline at end of file diff --git a/package.json b/package.json index 6af0354..4463a35 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,14 @@ "axios": "^0.21.0", "clsx": "^1.1.1", "cypress": "^6.2.1", + "http-proxy-middleware": "^2.0.0", "husky": "^4.3.7", "immer": "^8.0.0", "lint-staged": "^10.5.3", "lodash": "^4.17.20", "moment": "^2.29.1", "prettier": "^2.2.1", + "query-string": "^7.0.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-hook-form": "^6.14.1", @@ -45,7 +47,6 @@ "typescript": "^4.0.3", "web-vitals": "^0.2.4" }, - "proxy": "http://tr.zj9495.com", "scripts": { "start": "react-scripts start", "build": "react-scripts build", @@ -82,7 +83,8 @@ "eslint-plugin-promise": "^4.2.1", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", - "eslint-plugin-unicorn": "^26.0.1" + "eslint-plugin-unicorn": "^26.0.1", + "jest-matchmedia-mock": "^1.1.0" }, "husky": { "hooks": { diff --git a/src/appStart.ts b/src/appStart.ts index 4d3e5a4..d079a84 100644 --- a/src/appStart.ts +++ b/src/appStart.ts @@ -1,12 +1,51 @@ +/* eslint-disable class-methods-use-this */ import { LicenseInfo } from "@material-ui/x-grid"; +import store from "src/store"; +import { setLocale, toggleTheme } from "src/store/actions/rpc"; +import { DEFAULT_LANGUAGE, STORAGE_KEYS } from "src/constants"; +import { formatLocale } from "src/utils/formatter"; +import qs from "query-string"; + class AppStart { - // eslint-disable-next-line class-methods-use-this setupLicense() { LicenseInfo.setLicenseKey(process.env.REACT_APP_MUIX_LICENSE_KEY as string); } + setupLocale() { + const localeFromQs = qs.parse(window.location.search).locale as string; + const localeFromStorage = window.localStorage[STORAGE_KEYS.LOCALE]; + const localeFromStore = store.getState().rpc.locale; + const localeFromNavigator = formatLocale(navigator.language, false); + const locale = + formatLocale(localeFromQs, false) || + localeFromStore || + localeFromStorage || + localeFromNavigator || + DEFAULT_LANGUAGE.code; + setLocale(locale)(store.dispatch); + if (localeFromQs) { + const url = window.location.href.replace(`locale=${localeFromQs}`, ""); + + window.history.replaceState( + { + url, + title: "", + }, + "", + url + ); + } + } + setupTheme() { + const themeFromStorage = window.localStorage[STORAGE_KEYS.THEME]; + if (themeFromStorage) { + toggleTheme(themeFromStorage)(store.dispatch, store.getState); + } + } start() { this.setupLicense(); + this.setupLocale(); + this.setupTheme(); } } diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 9e7ed18..1f596c7 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -16,11 +16,10 @@ import { import SearchIcon from "@material-ui/icons/Search"; import GitHubIcon from "@material-ui/icons/GitHub"; import MenuIcon from "@material-ui/icons/Menu"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import { useIntl } from "react-intl"; import { toggleMenuOpen } from "src/store/actions/rpc"; -import { IState } from "src/types"; import { GITHUB_REPO } from "src/constants"; import ThemeToggle from "src/components/ThemeToggle"; import LanguageToggle from "src/components/LanguageToggle"; @@ -32,6 +31,8 @@ const useStyles = makeStyles((theme: Theme) => }, appBar: { zIndex: 1201, + backgroundColor: + theme.palette.type === "dark" ? "transparent" : undefined, }, menuButton: { marginRight: theme.spacing(2), @@ -98,21 +99,13 @@ export default function SearchAppBar() { const dispatch = useDispatch(); const intl = useIntl(); - const themeType = useSelector((state: IState) => state.rpc.theme); - - const appBarColor = themeType === "dark" ? "transparent" : undefined; - const handleMenuClick = () => { dispatch(toggleMenuOpen()); }; return (
- + { ) : undefined } > - {message} + {message} diff --git a/src/components/RemoveTorrentDialog/index.tsx b/src/components/RemoveTorrentDialog/index.tsx index bcf9bf9..699cc1e 100644 --- a/src/components/RemoveTorrentDialog/index.tsx +++ b/src/components/RemoveTorrentDialog/index.tsx @@ -23,6 +23,7 @@ import { toggleRemoveTorrentsDialog, setMessageBar, } from "src/store/actions/app"; +import { setSelectedIds } from "src/store/actions/rpc"; import { removeTorrents } from "src/api"; interface IFormInput { @@ -71,7 +72,10 @@ const AddTorrentDialog = () => { }) ); removeTorrents(selectIds, data.deleteLocalData) - .then((result) => handleResult(result.data as TResult)) + .then((result) => { + handleResult(result.data as TResult); + dispatch(setSelectedIds([])); + }) .catch(() => { dispatch( setMessageBar({ diff --git a/src/components/ThemeToggle/index.tsx b/src/components/ThemeToggle/index.tsx index 3144335..387ee1d 100644 --- a/src/components/ThemeToggle/index.tsx +++ b/src/components/ThemeToggle/index.tsx @@ -23,12 +23,13 @@ const ThemeToggle = () => { - {theme === "light" && } - {theme === "dark" && } - {theme === "auto" && } + {theme === "light" && } + {theme === "dark" && } + {theme === "auto" && } ); diff --git a/src/components/TorrentTable/index.tsx b/src/components/TorrentTable/index.tsx index e359d72..3504736 100644 --- a/src/components/TorrentTable/index.tsx +++ b/src/components/TorrentTable/index.tsx @@ -13,7 +13,7 @@ import { makeStyles, Theme, createStyles } from "@material-ui/core/styles"; import { useParams } from "react-router-dom"; -import { getTorrents } from "src/store/selector"; +import { getTorrents, getSelectedIds } from "src/store/selector"; import { setSelectedIds } from "src/store/actions/rpc"; import { formatSize, @@ -135,8 +135,22 @@ const columns: GridColDef[] = [ ) as unknown) as string, ...useSpeed, }, + { + field: "addedDate", + headerName: (( + + ) as unknown) as string, + ...useTime, + }, + { + field: "downloadDir", + headerName: (( + + ) as unknown) as string, + }, { field: "completeSize", + hide: true, headerName: (( ) as unknown) as string, @@ -144,38 +158,29 @@ const columns: GridColDef[] = [ }, { field: "uploadedEver", + hide: true, headerName: (( ) as unknown) as string, ...useSize, }, - { - field: "addedDate", - headerName: (( - - ) as unknown) as string, - ...useTime, - }, { field: "queuePosition", + hide: true, headerName: (( ) as unknown) as string, }, { field: "trackers", + hide: true, headerName: (( ) as unknown) as string, }, - { - field: "downloadDir", - headerName: (( - - ) as unknown) as string, - }, { field: "activityDate", + hide: true, headerName: (( ) as unknown) as string, @@ -183,12 +188,14 @@ const columns: GridColDef[] = [ }, { field: "labels", + hide: true, headerName: (( ) as unknown) as string, }, { field: "doneDate", + hide: true, headerName: (( ) as unknown) as string, @@ -199,6 +206,7 @@ const columns: GridColDef[] = [ const TorrentTable: React.FC = () => { const { torrentStatus } = useParams(); const torrents = useSelector(getTorrents); + const selectedIds = useSelector(getSelectedIds); const dispatch = useDispatch(); const classes = useStyles(); @@ -220,6 +228,7 @@ const TorrentTable: React.FC = () => { }} rows={rows} columns={columns} + selectionModel={selectedIds} pageSize={20} checkboxSelection disableSelectionOnClick diff --git a/src/constants.ts b/src/constants.ts index 43d6723..d58010c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import { IStatusColor } from "./types"; export const APP_ROUTES = { @@ -11,65 +12,87 @@ export const LANGUAGES = [ { code: "en", text: "English", + langFile: require("src/i18n/lang/en.json"), }, { code: "zh-CN", text: "中文 - 简体", + langFile: require("src/i18n/lang/zh_CN.json"), }, { code: "zh-TW", text: "中文 - 繁体", + langFile: require("src/i18n/lang/zh_TW.json"), }, { code: "de", text: "Deutsch", + langFile: require("src/i18n/lang/de.json"), }, { code: "es", text: "Español", + langFile: require("src/i18n/lang/es.json"), }, { code: "fr", text: "Français", + langFile: require("src/i18n/lang/fr.json"), }, { code: "hu", text: "Magyar", + langFile: require("src/i18n/lang/hu.json"), }, { code: "it", text: "Italiano", + langFile: require("src/i18n/lang/it.json"), }, { code: "ko", text: "Korean", + langFile: require("src/i18n/lang/ko.json"), }, { code: "nl", text: "Nederlands", + langFile: require("src/i18n/lang/nl.json"), + }, + { + code: "pl", + text: "Polska - Polski", + langFile: require("src/i18n/lang/pl.json"), }, { code: "pt-BR", text: "Português - Brasil", + langFile: require("src/i18n/lang/pt_BR.json"), }, { code: "pt-PT", text: "Português - Portugal", + langFile: require("src/i18n/lang/pt_PT.json"), }, { code: "ro", text: "Romanian", + langFile: require("src/i18n/lang/ro.json"), }, { code: "ru", text: "Русский", + langFile: require("src/i18n/lang/ru.json"), }, { code: "uk", text: "українська мова", + langFile: require("src/i18n/lang/uk.json"), }, ]; +export const DEFAULT_LANGUAGE = LANGUAGES[0]; + export const GITHUB_REPO = "https://github.com/zj9495/transmission-client"; export const STATUS_COLORS: IStatusColor = { @@ -92,3 +115,8 @@ export const PRIORITY_NORMAL = 0; export const PRIORITY_LOW = -1; export const INVALID_STATUS = -1; + +export const STORAGE_KEYS = { + LOCALE: "LOCALE", + THEME: "THEME", +}; diff --git a/src/containers/Intl/index.tsx b/src/containers/Intl/index.tsx index 4611696..722aaed 100644 --- a/src/containers/Intl/index.tsx +++ b/src/containers/Intl/index.tsx @@ -1,9 +1,10 @@ -/* eslint-disable import/no-dynamic-require */ import React from "react"; import { IntlProvider } from "react-intl"; import { connect } from "react-redux"; +import { find } from "lodash"; import { IState } from "src/types"; +import { LANGUAGES, DEFAULT_LANGUAGE } from "src/constants"; interface Props { locale: string; @@ -11,15 +12,12 @@ interface Props { } function getMessages(locale: string): Record { - let messages: Record; - try { - // eslint-disable-next-line global-require - messages = require(`src/i18n/lang/${locale.replace("-", "_")}.json`); - } catch { - // eslint-disable-next-line global-require - messages = require(`src/i18n/lang/en.json`); - } - return messages; + const messages = find(LANGUAGES, { code: locale })?.langFile || {}; + const defaultMessages = DEFAULT_LANGUAGE.langFile; + return { + ...defaultMessages, + ...messages, + }; } const Intl = (props: Props) => { const { locale, children } = props; diff --git a/src/containers/Theme/index.tsx b/src/containers/Theme/index.tsx index 7b35320..064e801 100644 --- a/src/containers/Theme/index.tsx +++ b/src/containers/Theme/index.tsx @@ -1,13 +1,13 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { createMuiTheme, ThemeProvider } from "@material-ui/core/styles"; -import { connect } from "react-redux"; +import { useSelector } from "react-redux"; import { enUS, zhCN, Localization } from "@material-ui/core/locale"; +import { blue } from "@material-ui/core/colors"; -import { IState } from "src/types"; +import { Theme as ITheme } from "src/types"; +import { getLocale, getTheme } from "src/store/selector"; interface Props { - locale: string; - themeType: "light" | "dark" | "auto"; children?: JSX.Element; } @@ -26,8 +26,31 @@ function getMessages(locale: string): Localization { return muiLocale; } +const getThemeType = (theme: ITheme) => { + if (theme === "auto") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return theme; +}; + const Theme = (props: Props) => { - const { locale, themeType, children } = props; + const locale = useSelector(getLocale); + const themeFromStore = useSelector(getTheme); + const [themeType, setThemeType] = useState(getThemeType(themeFromStore)); + useEffect(() => { + setThemeType(getThemeType(themeFromStore)); + }, [themeFromStore]); + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const callback = () => { + setThemeType(getThemeType(themeFromStore)); + }; + media.addEventListener("change", callback); + return () => media.removeEventListener("change", callback); + }, []); + const { children } = props; const messages = getMessages(locale); const theme = createMuiTheme( { @@ -35,7 +58,11 @@ const Theme = (props: Props) => { fontSize: 12, }, palette: { - type: themeType === "auto" ? undefined : themeType, + type: themeType, + primary: { + main: themeType === "light" ? blue[700] : blue[200], + contrastText: "#fff", + }, }, }, messages @@ -44,9 +71,4 @@ const Theme = (props: Props) => { return {children}; }; -const mapStateToProps = (state: IState) => ({ - locale: state.rpc.locale, - themeType: state.rpc.theme, -}); - -export default connect(mapStateToProps)(Theme); +export default Theme; diff --git a/src/setupProxy.js b/src/setupProxy.js new file mode 100644 index 0000000..e0bf513 --- /dev/null +++ b/src/setupProxy.js @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { createProxyMiddleware } = require("http-proxy-middleware"); + +const proxyDelay = function (req, res, next) { + const delay = Number(process.env.REACT_APP_RPC_DELAY); + if (delay) { + setTimeout(next, delay); + } else { + next(); + } +}; +module.exports = function (app) { + app.use( + "/transmission/rpc", + proxyDelay, + createProxyMiddleware({ + target: process.env.REACT_APP_TRANSMISSION_RPC, + changeOrigin: true, + }) + ); +}; diff --git a/src/setupTests.ts b/src/setupTests.ts index 1dd407a..8e41c7d 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,3 +3,8 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; +// eslint-disable-next-line import/no-extraneous-dependencies +import MatchMediaMock from "jest-matchmedia-mock"; + +// eslint-disable-next-line no-new +new MatchMediaMock(); diff --git a/src/store/actions/rpc.ts b/src/store/actions/rpc.ts index f1eadd6..f988aa5 100644 --- a/src/store/actions/rpc.ts +++ b/src/store/actions/rpc.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/ban-types */ import { ThunkDispatch } from "redux-thunk"; import { AnyAction } from "redux"; -import { STATUS_TYPES } from "src/constants"; -import { ITorrent, ITorrents, IState } from "src/types"; +import { STATUS_TYPES, STORAGE_KEYS } from "src/constants"; +import { ITorrent, ITorrents, IState, Theme } from "src/types"; import { getSession, getTorrents, getSessionStats } from "src/api"; import { objectToCamelCase } from "src/utils/object"; @@ -23,18 +23,26 @@ export const setLocale = (val: string) => ( type: SET_LOCALE, payload: val, }); + window.localStorage.setItem(STORAGE_KEYS.LOCALE, val); }; -export const toggleTheme = () => ( +export const toggleTheme = (theme?: Theme) => ( dispatch: ThunkDispatch<{}, {}, AnyAction>, getState: () => IState ) => { + const nextThemeMap: Record = { + light: "dark", + dark: "auto", + auto: "light", + }; const state = getState(); - const payload = state.rpc.theme === "light" ? "dark" : "light"; + const currentTheme: Theme = state.rpc.theme; + const nextTheme: Theme = theme || nextThemeMap[currentTheme]; dispatch({ type: CHANGE_THEME, - payload, + payload: nextTheme, }); + window.localStorage.setItem(STORAGE_KEYS.THEME, nextTheme); }; export const getSessionAction = () => ( diff --git a/src/store/reducers/rpc.ts b/src/store/reducers/rpc.ts index 4ae1236..e24472b 100644 --- a/src/store/reducers/rpc.ts +++ b/src/store/reducers/rpc.ts @@ -13,8 +13,8 @@ import { } from "../constants"; export const initialRPCState: IRPCState = { - locale: "en", - theme: "light", + locale: "", + theme: "auto", sessionId: undefined, torrents: { all: [], diff --git a/src/store/selector.ts b/src/store/selector.ts index c66b087..363b782 100644 --- a/src/store/selector.ts +++ b/src/store/selector.ts @@ -1,6 +1,7 @@ import { IState } from "src/types"; export const getLocale = (state: IState) => state.rpc.locale; +export const getTheme = (state: IState) => state.rpc.theme; export const getSessionSelector = (state: IState) => state.rpc.session; export const getDownloadDirSelector = (state: IState) => diff --git a/src/types.ts b/src/types.ts index 922615b..04faa35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,9 +64,11 @@ export interface IStats { uploadSpeed: number; } +export type Theme = "light" | "dark" | "auto"; + export interface IRPCState { locale: string; - theme: "light" | "dark" | "auto"; + theme: Theme; sessionId: string | undefined; torrents: ITorrents; session: ISession; diff --git a/src/utils/formatter.test.ts b/src/utils/formatter.test.ts index 6655e0b..4b56b32 100644 --- a/src/utils/formatter.test.ts +++ b/src/utils/formatter.test.ts @@ -5,6 +5,7 @@ import { formatSpeed, formatUnixTimeStamp, formatLeftTime, + formatLocale, } from "./formatter"; describe.each` @@ -166,3 +167,122 @@ describe.each` expect(formatLeftTime(ms)).toBe(expected); }); }); + +describe.each` + locale | expected + ${"en"} | ${"en"} + ${"en-US"} | ${"en"} + ${"en-AU"} | ${"en"} + ${"en-CA"} | ${"en"} + ${"en-GB"} | ${"en"} + ${"de"} | ${"de"} + ${"de-DE"} | ${"de"} + ${"es"} | ${"es"} + ${"es-ES"} | ${"es"} + ${"es-PR"} | ${"es"} + ${"fr"} | ${"fr"} + ${"fr-FR"} | ${"fr"} + ${"fr-CA"} | ${"fr"} + ${"hu"} | ${"hu"} + ${"hu-HU"} | ${"hu"} + ${"it"} | ${"it"} + ${"it-IT"} | ${"it"} + ${"it-CH"} | ${"it"} + ${"ko"} | ${"ko"} + ${"ko-KR"} | ${"ko"} + ${"nl"} | ${"nl"} + ${"nl-BE"} | ${"nl"} + ${"nl-NL"} | ${"nl"} + ${"pl"} | ${"pl"} + ${"pt"} | ${"pt-BR"} + ${"pt-BR"} | ${"pt-BR"} + ${"pt-PT"} | ${"pt-PT"} + ${"ro"} | ${"ro"} + ${"ro-RO"} | ${"ro"} + ${"ru"} | ${"ru"} + ${"ru-RU"} | ${"ru"} + ${"uk"} | ${"uk"} + ${"uk-UA"} | ${"uk"} + ${"zh"} | ${"zh-CN"} + ${"zh-CN"} | ${"zh-CN"} + ${"zh-TW"} | ${"zh-TW"} + ${"zh-HK"} | ${"zh-TW"} +`("test formatLocale($locale)", ({ locale, expected }) => { + test(`returns ${expected}`, () => { + expect(formatLocale(locale)).toBe(expected); + }); +}); + +describe.each` + locale | expected + ${"en_US"} | ${"en"} + ${"en_AU"} | ${"en"} + ${"en_CA"} | ${"en"} + ${"en_GB"} | ${"en"} + ${"de_DE"} | ${"de"} + ${"es_ES"} | ${"es"} + ${"es_PR"} | ${"es"} + ${"fr_FR"} | ${"fr"} + ${"fr_CA"} | ${"fr"} + ${"hu_HU"} | ${"hu"} + ${"it_IT"} | ${"it"} + ${"it_CH"} | ${"it"} + ${"ko_KR"} | ${"ko"} + ${"nl_BE"} | ${"nl"} + ${"nl_NL"} | ${"nl"} + ${"pt_BR"} | ${"pt-BR"} + ${"pt_PT"} | ${"pt-PT"} + ${"zh_CN"} | ${"zh-CN"} + ${"zh_TW"} | ${"zh-TW"} + ${"zh_HK"} | ${"zh-TW"} +`("should correctly formatLocale($locale) with _", ({ locale, expected }) => { + test(`returns ${expected}`, () => { + expect(formatLocale(locale)).toBe(expected); + }); +}); + +describe.each` + unsupportedLoacle | expected + ${"af"} | ${"en"} + ${"af-ZA"} | ${"en"} + ${"sq-AL"} | ${"en"} + ${"ar"} | ${"en"} + ${"ar-DZ"} | ${"en"} + ${"hy-AM"} | ${"en"} + ${"eu-ES"} | ${"en"} +`( + "should return `en` when formatLocale($unsupportedLoacle)", + ({ unsupportedLoacle, expected }) => { + test(`returns ${expected}`, () => { + expect(formatLocale(unsupportedLoacle)).toBe(expected); + }); + } +); + +describe.each` + locale | useDefaultLang | expected + ${undefined} | ${false} | ${undefined} + ${undefined} | ${true} | ${"en"} + ${undefined} | ${undefined} | ${"en"} + ${"af"} | ${false} | ${undefined} + ${"af"} | ${true} | ${"en"} + ${"af-ZA"} | ${false} | ${undefined} + ${"af-ZA"} | ${true} | ${"en"} + ${"sq-AL"} | ${false} | ${undefined} + ${"sq-AL"} | ${true} | ${"en"} + ${"ar"} | ${false} | ${undefined} + ${"ar"} | ${true} | ${"en"} + ${"ar-DZ"} | ${false} | ${undefined} + ${"ar-DZ"} | ${true} | ${"en"} + ${"hy-AM"} | ${false} | ${undefined} + ${"hy-AM"} | ${true} | ${"en"} + ${"eu-ES"} | ${false} | ${undefined} + ${"eu-ES"} | ${true} | ${"en"} +`( + "test formatLocale($locale, $useDefaultLang) with useDefaultLang param", + ({ locale, useDefaultLang, expected }) => { + test(`returns ${expected}`, () => { + expect(formatLocale(locale, useDefaultLang)).toBe(expected); + }); + } +); diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index ef50f18..d5ec517 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -1,6 +1,8 @@ import moment from "moment"; +import { find } from "lodash"; import store from "src/store"; +import { LANGUAGES, DEFAULT_LANGUAGE } from "src/constants"; export const formatBytes = ( number: number, @@ -60,3 +62,25 @@ export const formatLeftTime = (second: number): string => { return `${hours}:${minutes}:${seconds}`; }; + +export const formatLocale = ( + locale = "", + useDefaultLang = true +): string | undefined => { + if (!locale && !useDefaultLang) { + // eslint-disable-next-line unicorn/no-useless-undefined + return undefined; + } + if (["zh_HK", "zh-HK"].includes(locale)) { + return "zh-TW"; + } + const [lang, country] = locale.split(/-|_/); + locale = `${lang}-${country}`; + let localeConfig = + find(LANGUAGES, (o) => o.code === lang || o.code === locale) || + find(LANGUAGES, (o) => o.code.startsWith(lang)); + if (useDefaultLang && !localeConfig) { + localeConfig = DEFAULT_LANGUAGE; + } + return localeConfig?.code; +}; diff --git a/yarn.lock b/yarn.lock index dc02654..bfee678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2770,6 +2770,13 @@ resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#3c9ee980f1a10d6021ae6632ca3e79ca2ec4fb50" integrity sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA== +"@types/http-proxy@^1.17.5": + version "1.17.6" + resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz#62dc3fade227d6ac2862c8f19ee0da9da9fd8616" + integrity sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -6682,6 +6689,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +filter-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" + integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= + finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" @@ -7404,7 +7416,18 @@ http-proxy-middleware@0.19.1: lodash "^4.17.11" micromatch "^3.1.10" -http-proxy@^1.17.0: +http-proxy-middleware@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz#20d1ac3409199c83e5d0383ba6436b04e7acb9fe" + integrity sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow== + dependencies: + "@types/http-proxy" "^1.17.5" + http-proxy "^1.18.1" + is-glob "^4.0.1" + is-plain-obj "^3.0.0" + micromatch "^4.0.2" + +http-proxy@^1.17.0, http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== @@ -7944,6 +7967,11 @@ is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= +is-plain-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" + integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== + is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -8314,6 +8342,11 @@ jest-matcher-utils@^26.6.0, jest-matcher-utils@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-matchmedia-mock@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jest-matchmedia-mock/-/jest-matchmedia-mock-1.1.0.tgz#eaae8c5d1dee4e4f7c59f8cb1b38b5d7ea842552" + integrity sha512-REnJRsOSCMpGAlkxmvVTqEBpregyFVi9MPEH3N83W1yLKzDdNehtCkcdDZduXq74PLtfI+11NyM4zKCK5ynV9g== + jest-message-util@^26.6.0, jest-message-util@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" @@ -11182,6 +11215,16 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-string@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/query-string/-/query-string-7.0.0.tgz#aaad2c8d5c6a6d0c6afada877fecbd56af79e609" + integrity sha512-Iy7moLybliR5ZgrK/1R3vjrXq03S13Vz4Rbm5Jg3EFq1LUmQppto0qtXz4vqZ386MSRjZgnTSZ9QC+NZOSd/XA== + dependencies: + decode-uri-component "^0.2.0" + filter-obj "^1.1.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -12508,6 +12551,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +split-on-first@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" + integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -12628,6 +12676,11 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= + string-argv@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"