diff --git a/.eslintrc b/.eslintrc index 1349351c..78ada1c2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,5 +14,8 @@ "process": true, "setImmediate": true }, - "ignorePatterns": ["src/service*.ts"] + "ignorePatterns": ["src/service*.ts"], + "rules": { + "complexity": "off" + } } diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 775cdcf7..b10f759d 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -17,25 +17,13 @@ jobs: uses: actions/setup-node@v1 with: node-version: 14.15 - - name: Generate slug - id: generate_slug - run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)" - name: Build run: | npm ci && \ - node ./scripts/set-homepage.js /shlink-web-client/${{ steps.generate_slug.outputs.slug }} && \ + node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \ rm src/service-worker.ts && \ npm run build - - name: Deploy - uses: JamesIves/github-pages-deploy-action@4.1.1 + - name: Deploy preview + uses: shlinkio/deploy-preview-action@v1.0.1 with: - branch: preview-env folder: build - target-folder: ${{ steps.generate_slug.outputs.slug }} - - name: Publish env - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: Preview environment - message: | - ## Preview environment - https://shlinkio.github.io/shlink-web-client/${{ steps.generate_slug.outputs.slug }}/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c13911ec..0a733642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.3.0] - 2021-09-25 +### Added +* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher. +* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title. +* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags. +* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured: + + * `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far. + * `includes`: Suggests tags that contain the input. + +* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released. +* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher. +* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags. + + The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want. + + You can also configure the default mode from settings. + +### Changed +* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5 +* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand. +* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check. + +### Deprecated +* *Nothing* + +### Removed +* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0. + +### Fixed +* *Nothing* + + ## [3.2.1] - 2021-09-12 ### Added * *Nothing* @@ -18,9 +51,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * *Nothing* ### Fixed -* [#478](https://github.com/shlinkio/shlink-web-client/pull/478) Fixed tags including special chars not being properly URL encoded before using them as query params. -* [#480](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed servers import on Chromium-based browsers when using windows. -* [#482](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc). +* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params. +* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows. +* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc). ## [3.2.0] - 2021-07-12 @@ -32,16 +65,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided. * [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder. -* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7. -* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7. -* [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7. -* [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design. -* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab. +* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7. +* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7. +* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7. +* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design. +* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab. ### Changed -* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer. -* [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns. -* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`. +* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer. +* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns. +* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`. ### Deprecated * *Nothing* @@ -50,7 +83,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * *Nothing* ### Fixed -* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break. +* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break. ## [3.1.2] - 2021-06-06 diff --git a/jest.config.js b/jest.config.js index d1896e6f..3745d53d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,13 +1,20 @@ module.exports = { coverageDirectory: '/coverage', collectCoverageFrom: [ - 'src/**/*.{js,ts,tsx}', - '!src/registerServiceWorker.js', - '!src/index.ts', + 'src/**/*.{ts,tsx}', + '!src/*.{ts,tsx}', '!src/reducers/index.ts', '!src/**/provideServices.ts', '!src/container/*.ts', ], + coverageThreshold: { + global: { + statements: 85, + branches: 75, + functions: 80, + lines: 85, + }, + }, resolver: 'jest-pnp-resolver', setupFiles: [ 'react-app-polyfill/jsdom', diff --git a/package-lock.json b/package-lock.json index e3daeb0d..8653490d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6363,15 +6363,6 @@ "@babel/types": "^7.3.0" } }, - "@types/chart.js": { - "version": "2.9.31", - "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz", - "integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==", - "dev": true, - "requires": { - "moment": "^2.10.2" - } - }, "@types/cheerio": { "version": "0.22.22", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz", @@ -10578,30 +10569,9 @@ "dev": true }, "chart.js": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz", - "integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==", - "requires": { - "chartjs-color": "^2.1.0", - "moment": "^2.10.2" - } - }, - "chartjs-color": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz", - "integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==", - "requires": { - "chartjs-color-string": "^0.6.0", - "color-convert": "^1.9.3" - } - }, - "chartjs-color-string": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz", - "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==", - "requires": { - "color-name": "^1.0.0" - } + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz", + "integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ==" }, "check-types": { "version": "11.1.2", @@ -10957,6 +10927,7 @@ "version": "1.9.3", "resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=", + "dev": true, "requires": { "color-name": "1.1.3" }, @@ -10964,14 +10935,16 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true } } }, "color-name": { "version": "1.1.4", "resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=" + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", + "dev": true }, "color-string": { "version": "1.5.4", @@ -19000,11 +18973,6 @@ } } }, - "moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" - }, "moo": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", @@ -24570,18 +24538,17 @@ } }, "react-chartjs-2": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz", - "integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz", + "integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==", "requires": { - "lodash": "^4.17.19", - "prop-types": "^15.7.2" + "lodash": "^4.17.19" }, "dependencies": { "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" } } }, diff --git a/package.json b/package.json index efe43b57..dbfd828f 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,18 @@ "license": "MIT", "scripts": { "lint": "npm run lint:css && npm run lint:js", - "lint:js": "eslint --ext .js,.ts,.tsx src test", - "lint:js:fix": "npm run lint:js -- --fix", "lint:css": "stylelint src/*.scss src/**/*.scss", + "lint:js": "eslint --ext .js,.ts,.tsx src test", + "lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:css:fix": "npm run lint:css -- --fix", + "lint:js:fix": "npm run lint:js -- --fix", "start": "node scripts/start.js", "serve:build": "serve ./build", "build": "node scripts/build.js", "test": "node scripts/test.js --env=jsdom --colors --verbose", - "test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover", - "test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html", + "test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary", + "test:ci": "npm run test:coverage -- --coverageReporters=clover", + "test:pretty": "npm run test:coverage -- --coverageReporters=html", "mutate": "./node_modules/.bin/stryker run --concurrency 4" }, "dependencies": { @@ -29,7 +31,7 @@ "bootstrap": "^4.6.0", "bottlejs": "^2.0.0", "bowser": "^2.11.0", - "chart.js": "^2.9.4", + "chart.js": "^3.5.1", "classnames": "^2.2.6", "compare-versions": "^3.6.0", "csvjson": "^5.1.0", @@ -40,7 +42,7 @@ "qs": "^6.9.6", "ramda": "^0.27.1", "react": "^17.0.1", - "react-chartjs-2": "^2.11.1", + "react-chartjs-2": "^3.0.4", "react-color": "^2.19.3", "react-copy-to-clipboard": "^5.0.2", "react-datepicker": "^3.6.0", @@ -71,7 +73,6 @@ "@stryker-mutator/jest-runner": "^5.0.0", "@stryker-mutator/typescript-checker": "^5.0.0", "@svgr/webpack": "^5.5.0", - "@types/chart.js": "^2.9.31", "@types/classnames": "^2.2.11", "@types/enzyme": "^3.10.8", "@types/jest": "^26.0.20", diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 79ae02bb..315be26b 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -1,4 +1,3 @@ -import qs from 'qs'; import { isEmpty, isNil, reject } from 'ramda'; import { AxiosInstance, AxiosResponse, Method } from 'axios'; import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams'; @@ -16,7 +15,10 @@ import { ShlinkDomain, ShlinkDomainsResponse, ShlinkVisitsOverview, + ShlinkEditDomainRedirects, + ShlinkDomainRedirects, } from '../types'; +import { stringifyQuery } from '../../utils/helpers/query'; const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; const rejectNilProps = reject(isNil); @@ -108,6 +110,11 @@ export default class ShlinkApiClient { public readonly listDomains = async (): Promise => this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data); + public readonly editDomainRedirects = async ( + domainRedirects: ShlinkEditDomainRedirects, + ): Promise => + this.performRequest('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data); + private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => { try { return await this.axios({ @@ -116,7 +123,7 @@ export default class ShlinkApiClient { headers: { 'X-Api-Key': this.apiKey }, params: rejectNilProps(query), data: body, - paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }), + paramsSerializer: stringifyQuery, }); } catch (e) { const { response } = e; diff --git a/src/api/types/actions.ts b/src/api/types/actions.ts new file mode 100644 index 00000000..5359b6b3 --- /dev/null +++ b/src/api/types/actions.ts @@ -0,0 +1,6 @@ +import { Action } from 'redux'; +import { ProblemDetailsError } from './index'; + +export interface ApiErrorAction extends Action { + errorData?: ProblemDetailsError; +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts index acd0d4f7..b49e4338 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -65,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta { tags?: string[]; } +export interface ShlinkDomainRedirects { + baseUrlRedirect: string | null; + regular404Redirect: string | null; + invalidShortUrlRedirect: string | null; +} + +export interface ShlinkEditDomainRedirects extends Partial { + domain: string; +} + export interface ShlinkDomain { domain: string; isDefault: boolean; + redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8 } export interface ShlinkDomainsResponse { diff --git a/src/App.scss b/src/app/App.scss similarity index 93% rename from src/App.scss rename to src/app/App.scss index a6566e27..538c0179 100644 --- a/src/App.scss +++ b/src/app/App.scss @@ -1,4 +1,4 @@ -@import './utils/base'; +@import '../utils/base'; .app-container { height: 100%; diff --git a/src/App.tsx b/src/app/App.tsx similarity index 83% rename from src/App.tsx rename to src/app/App.tsx index d8ac9b2a..d8b91056 100644 --- a/src/App.tsx +++ b/src/app/App.tsx @@ -1,11 +1,11 @@ import { useEffect, FC } from 'react'; import { Route, Switch } from 'react-router-dom'; -import NotFound from './common/NotFound'; -import { ServersMap } from './servers/data'; -import { Settings } from './settings/reducers/settings'; -import { changeThemeInMarkup } from './utils/theme'; -import { AppUpdateBanner } from './common/AppUpdateBanner'; -import { forceUpdate } from './utils/helpers/sw'; +import NotFound from '../common/NotFound'; +import { ServersMap } from '../servers/data'; +import { Settings } from '../settings/reducers/settings'; +import { changeThemeInMarkup } from '../utils/theme'; +import { AppUpdateBanner } from '../common/AppUpdateBanner'; +import { forceUpdate } from '../utils/helpers/sw'; import './App.scss'; interface AppProps { diff --git a/src/app/services/provideServices.ts b/src/app/services/provideServices.ts index 1564b874..4dff9f31 100644 --- a/src/app/services/provideServices.ts +++ b/src/app/services/provideServices.ts @@ -1,6 +1,6 @@ import Bottle from 'bottlejs'; import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates'; -import App from '../../App'; +import App from '../App'; import { ConnectDecorator } from '../../container/types'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { diff --git a/src/common/AsideMenu.tsx b/src/common/AsideMenu.tsx index 3d9fd4b8..a301d4ef 100644 --- a/src/common/AsideMenu.tsx +++ b/src/common/AsideMenu.tsx @@ -4,6 +4,7 @@ import { faTags as tagsIcon, faPen as editIcon, faHome as overviewIcon, + faGlobe as domainsIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FC } from 'react'; @@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom'; import classNames from 'classnames'; import { Location } from 'history'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; -import { ServerWithId } from '../servers/data'; +import { isServerWithId, SelectedServer } from '../servers/data'; +import { supportsDomainRedirects } from '../utils/helpers/features'; import './AsideMenu.scss'; export interface AsideMenuProps { - selectedServer: ServerWithId; + selectedServer: SelectedServer; className?: string; showOnMobile?: boolean; } @@ -38,7 +40,8 @@ const AsideMenuItem: FC = ({ children, to, className, ...res const AsideMenu = (DeleteServerButton: FC) => ( { selectedServer, showOnMobile = false }: AsideMenuProps, ) => { - const serverId = selectedServer ? selectedServer.id : ''; + const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; + const addManageDomainsLink = supportsDomainRedirects(selectedServer); const asideClass = classNames('aside-menu', { 'aside-menu--hidden': !showOnMobile, }); @@ -49,30 +52,38 @@ const AsideMenu = (DeleteServerButton: FC) => ( ); diff --git a/src/common/MainHeader.tsx b/src/common/MainHeader.tsx index 4c213a7b..245a0499 100644 --- a/src/common/MainHeader.tsx +++ b/src/common/MainHeader.tsx @@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)