diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ce7315d..d129ff33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,6 @@ jobs: ci: uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main with: - node-version: 16.13 + node-version: 16.15 with-mutation-tests: true publish-coverage: true diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 69b4cdb3..b958cc37 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -16,7 +16,7 @@ jobs: - name: Use node.js uses: actions/setup-node@v1 with: - node-version: 16.13 + node-version: 16.15 - name: Build run: | npm ci && \ diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 8306b7e7..1ab257a4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -14,7 +14,7 @@ jobs: - name: Use node.js uses: actions/setup-node@v1 with: - node-version: 16.13 + node-version: 16.15 - name: Generate release assets run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist - name: Publish release with assets diff --git a/CHANGELOG.md b/CHANGELOG.md index cc41f9cc..25c52727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.7.1] - 2022-05-25 +### Added +* *Nothing* + +### Changed +* [#648](https://github.com/shlinkio/shlink-web-client/pull/648) Migrated some scripts to ESM and updated to chalk 5. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#653](https://github.com/shlinkio/shlink-web-client/pull/653) Fixed rendering values greater than 1000 in charts, when the browser has certain locales configured. + + ## [3.7.0] - 2022-05-14 ### Added * [#622](https://github.com/shlinkio/shlink-web-client/pull/622) Added support to load domain visits when consuming Shlink 3.1.0 or newer. diff --git a/Dockerfile b/Dockerfile index 2c6eb41f..d907daa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.13-alpine as node +FROM node:16.15-alpine as node COPY . /shlink-web-client ARG VERSION="latest" ENV VERSION ${VERSION} diff --git a/docker-compose.yml b/docker-compose.yml index a2a9db6b..3c8589a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: shlink_web_client_node: container_name: shlink_web_client_node - image: node:16.13-alpine + image: node:16.15-alpine command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start" volumes: - ./:/home/shlink/www diff --git a/package-lock.json b/package-lock.json index 6bcb9400..959a29dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4436,22 +4436,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@stryker-mutator/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@stryker-mutator/core/node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4616,6 +4600,22 @@ "node": ">=8.0.0" } }, + "node_modules/@stryker-mutator/core/node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@stryker-mutator/core/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -29962,16 +29962,6 @@ "color-convert": "^2.0.1" } }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -30088,6 +30078,18 @@ "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } } }, "is-fullwidth-code-point": { diff --git a/package.json b/package.json index 93dcb97d..6f86668e 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "lint:css:fix": "npm run lint:css -- --fix", "lint:js:fix": "npm run lint:js -- --fix", "start": "DISABLE_ESLINT_PLUGIN=true react-scripts start", - "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.js", - "build:dist": "npm run build && node scripts/create-dist-file.js", + "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.mjs", + "build:dist": "npm run build && node scripts/create-dist-file.mjs", "build:serve": "serve -p 5000 ./build", "test": "jest --env=jsdom --colors --verbose", "test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary", diff --git a/scripts/create-dist-file.js b/scripts/create-dist-file.mjs similarity index 90% rename from scripts/create-dist-file.js rename to scripts/create-dist-file.mjs index 2c166a9b..96c454d4 100644 --- a/scripts/create-dist-file.js +++ b/scripts/create-dist-file.mjs @@ -2,9 +2,9 @@ process.env.BABEL_ENV = 'production'; process.env.NODE_ENV = 'production'; -const chalk = require('chalk'); -const AdmZip = require('adm-zip'); -const fs = require('fs'); +import chalk from 'chalk'; +import AdmZip from 'adm-zip'; +import fs from 'fs'; function zipDist(version) { const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`; diff --git a/scripts/replace-version.js b/scripts/replace-version.mjs similarity index 96% rename from scripts/replace-version.js rename to scripts/replace-version.mjs index 970d4583..38e49682 100644 --- a/scripts/replace-version.js +++ b/scripts/replace-version.mjs @@ -1,4 +1,4 @@ -const fs = require('fs'); +import fs from 'fs'; function replaceVersionPlaceholder(version) { const staticJsFilesPath = './build/static/js'; diff --git a/src/domains/helpers/EditDomainRedirectsModal.tsx b/src/domains/helpers/EditDomainRedirectsModal.tsx index 2706a210..0bc86bf8 100644 --- a/src/domains/helpers/EditDomainRedirectsModal.tsx +++ b/src/domains/helpers/EditDomainRedirectsModal.tsx @@ -38,7 +38,7 @@ export const EditDomainRedirectsModal: FC = ( return ( -
+ Edit redirects for {domain.domain} diff --git a/src/servers/ServersDropdown.tsx b/src/servers/ServersDropdown.tsx index cf619228..e549471a 100644 --- a/src/servers/ServersDropdown.tsx +++ b/src/servers/ServersDropdown.tsx @@ -10,7 +10,7 @@ export interface ServersDropdownProps { selectedServer: SelectedServer; } -const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => { +export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => { const serversList = values(servers); const renderServers = () => { @@ -46,5 +46,3 @@ const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => { ); }; - -export default ServersDropdown; diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index fcb04a46..9f177cb6 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -1,6 +1,6 @@ import Bottle from 'bottlejs'; import CreateServer from '../CreateServer'; -import ServersDropdown from '../ServersDropdown'; +import { ServersDropdown } from '../ServersDropdown'; import DeleteServerModal from '../DeleteServerModal'; import DeleteServerButton from '../DeleteServerButton'; import { EditServer } from '../EditServer'; diff --git a/src/utils/helpers/charts.ts b/src/utils/helpers/charts.ts index 5a849064..7914eee8 100644 --- a/src/utils/helpers/charts.ts +++ b/src/utils/helpers/charts.ts @@ -11,8 +11,6 @@ export const pointerOnHover = ({ native }: ChartEvent, [firstElement]: ActiveEle canvas.style.cursor = firstElement ? 'pointer' : 'default'; }; -export const renderChartLabel = ({ dataset, formattedValue }: TooltipItem) => - `${dataset.label}: ${prettify(formattedValue)}`; +export const renderChartLabel = ({ dataset, raw }: TooltipItem) => `${dataset.label}: ${prettify(`${raw}`)}`; -export const renderPieChartLabel = ({ label, formattedValue }: TooltipItem) => - `${label}: ${prettify(formattedValue)}`; +export const renderPieChartLabel = ({ label, raw }: TooltipItem) => `${label}: ${prettify(`${raw}`)}`; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index f0e35b89..59885d5f 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -300,19 +300,19 @@ const VisitsStats: FC = ({ {visits.length > 0 && (
- exportCsv(normalizedVisits)} /> +
)} diff --git a/test/domains/helpers/EditDomainRedirectsModal.test.tsx b/test/domains/helpers/EditDomainRedirectsModal.test.tsx index 067f5f3b..3fc7205a 100644 --- a/test/domains/helpers/EditDomainRedirectsModal.test.tsx +++ b/test/domains/helpers/EditDomainRedirectsModal.test.tsx @@ -1,12 +1,10 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Mock } from 'ts-mockery'; -import { Button, ModalHeader } from 'reactstrap'; import { ShlinkDomain } from '../../../src/api/types'; import { EditDomainRedirectsModal } from '../../../src/domains/helpers/EditDomainRedirectsModal'; -import { InfoTooltip } from '../../../src/utils/InfoTooltip'; describe('', () => { - let wrapper: ShallowWrapper; const editDomainRedirects = jest.fn().mockResolvedValue(undefined); const toggle = jest.fn(); const domain = Mock.of({ @@ -15,81 +13,67 @@ describe('', () => { baseUrlRedirect: 'baz', }, }); - - beforeEach(() => { - wrapper = shallow( + const setUp = () => ({ + user: userEvent.setup(), + ...render( , - ); + ), }); afterEach(jest.clearAllMocks); - afterEach(() => wrapper?.unmount()); it('renders domain in header', () => { - const header = wrapper.find(ModalHeader); - - expect(header.html()).toContain('foo.com'); + setUp(); + expect(screen.getByRole('heading')).toHaveTextContent('Edit redirects for foo.com'); }); - it('expected amount of form groups and tooltips', () => { - const formGroups = wrapper.find('FormGroup'); - const tooltips = wrapper.find(InfoTooltip); + it('has different handlers to toggle the modal', async () => { + const { user } = setUp(); - expect(formGroups).toHaveLength(3); - expect(tooltips).toHaveLength(3); - }); - - it('has different handlers to toggle the modal', () => { expect(toggle).not.toHaveBeenCalled(); - - (wrapper.prop('toggle') as Function)(); - (wrapper.find(ModalHeader).prop('toggle') as Function)(); - wrapper.find(Button).first().simulate('click'); - - expect(toggle).toHaveBeenCalledTimes(3); + await user.click(screen.getByLabelText('Close')); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(toggle).toHaveBeenCalledTimes(2); }); - it('saves expected values when form is submitted', () => { - const formGroups = wrapper.find('FormGroup'); + it('saves expected values when form is submitted', async () => { + const { user } = setUp(); + // TODO Using fire event because userEvent.click on the Submit button does not submit the form + const submitForm = () => fireEvent.submit(screen.getByRole('form')); expect(editDomainRedirects).not.toHaveBeenCalled(); - - wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); - expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + submitForm(); + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { baseUrlRedirect: 'baz', regular404Redirect: null, invalidShortUrlRedirect: null, - }); + })); - formGroups.at(0).simulate('change', 'new_base_url'); - formGroups.at(2).simulate('change', 'new_invalid_short_url'); - - wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); - expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + await user.clear(screen.getByDisplayValue('baz')); + await user.type(screen.getAllByPlaceholderText('No redirect')[0], 'new_base_url'); + await user.type(screen.getAllByPlaceholderText('No redirect')[2], 'new_invalid_short_url'); + submitForm(); + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { baseUrlRedirect: 'new_base_url', regular404Redirect: null, invalidShortUrlRedirect: 'new_invalid_short_url', - }); + })); - formGroups.at(1).simulate('change', 'new_regular_404'); - formGroups.at(2).simulate('change', ''); - - wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); - expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + await user.type(screen.getAllByPlaceholderText('No redirect')[1], 'new_regular_404'); + await user.clear(screen.getByDisplayValue('new_invalid_short_url')); + submitForm(); + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { baseUrlRedirect: 'new_base_url', regular404Redirect: 'new_regular_404', invalidShortUrlRedirect: null, - }); + })); - formGroups.at(0).simulate('change', ''); - formGroups.at(1).simulate('change', ''); - formGroups.at(2).simulate('change', ''); - - wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); - expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + await Promise.all(screen.getAllByPlaceholderText('No redirect').map((element) => user.clear(element))); + submitForm(); + await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { baseUrlRedirect: null, regular404Redirect: null, invalidShortUrlRedirect: null, - }); + })); }); }); diff --git a/test/servers/DeleteServerButton.test.tsx b/test/servers/DeleteServerButton.test.tsx index 12e7cb68..f66e8a95 100644 --- a/test/servers/DeleteServerButton.test.tsx +++ b/test/servers/DeleteServerButton.test.tsx @@ -1,29 +1,39 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import { ReactNode } from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; import { Mock } from 'ts-mockery'; import deleteServerButtonConstruct from '../../src/servers/DeleteServerButton'; import { ServerWithId } from '../../src/servers/data'; describe('', () => { - let wrapper: ShallowWrapper; - const DeleteServerModal = () => null; - - beforeEach(() => { - const DeleteServerButton = deleteServerButtonConstruct(DeleteServerModal); - - wrapper = shallow(()} className="button" />); - }); - afterEach(() => wrapper.unmount()); - - it('renders a button and a modal', () => { - expect(wrapper.find('.button')).toHaveLength(1); - expect(wrapper.find(DeleteServerModal)).toHaveLength(1); + const DeleteServerButton = deleteServerButtonConstruct( + ({ isOpen }) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}, + ); + const setUp = (children?: ReactNode) => ({ + user: userEvent.setup(), + ...render( + ()} textClassName="button">{children}, + ), }); - it('displays modal when button is clicked', () => { - const btn = wrapper.find('.button'); + it.each([ + ['Foo bar'], + ['baz'], + ['something'], + [undefined], + ])('renders expected content', (children) => { + const { container } = setUp(children); + expect(container.firstChild).toBeTruthy(); + expect(container.firstChild).toMatchSnapshot(); + }); - expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(false); - btn.simulate('click'); - expect(wrapper.find(DeleteServerModal).prop('isOpen')).toEqual(true); + it('displays modal when button is clicked', async () => { + const { user, container } = setUp(); + + expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Closed/); + expect(screen.getByText(/DeleteServerModal/)).not.toHaveTextContent(/Open/); + container.firstElementChild && await user.click(container.firstElementChild); + + await waitFor(() => expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Open/)); }); }); diff --git a/test/servers/ServersDropdown.test.tsx b/test/servers/ServersDropdown.test.tsx index eab1eca2..19e8e616 100644 --- a/test/servers/ServersDropdown.test.tsx +++ b/test/servers/ServersDropdown.test.tsx @@ -1,44 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { values } from 'ramda'; import { Mock } from 'ts-mockery'; -import { shallow, ShallowWrapper } from 'enzyme'; -import { DropdownItem, DropdownToggle } from 'reactstrap'; -import ServersDropdown from '../../src/servers/ServersDropdown'; -import { ServerWithId } from '../../src/servers/data'; +import { MemoryRouter } from 'react-router-dom'; +import { ServersDropdown } from '../../src/servers/ServersDropdown'; +import { ServersMap, ServerWithId } from '../../src/servers/data'; describe('', () => { - let wrapped: ShallowWrapper; - const servers = { + const fallbackServers: ServersMap = { '1a': Mock.of({ name: 'foo', id: '1a' }), '2b': Mock.of({ name: 'bar', id: '2b' }), '3c': Mock.of({ name: 'baz', id: '3c' }), }; - - beforeEach(() => { - wrapped = shallow(); - }); - afterEach(() => wrapped.unmount()); - - it('contains the list of servers, the divider, the create button and the export button', () => - expect(wrapped.find(DropdownItem)).toHaveLength(values(servers).length + 2)); - - it('contains a toggle with proper title', () => - expect(wrapped.find(DropdownToggle)).toHaveLength(1)); - - it('contains a button to export servers', () => { - const items = wrapped.find(DropdownItem); - - expect(items.filter('[divider]')).toHaveLength(1); - expect(items.filterWhere((item) => item.prop('to') === '/manage-servers')).toHaveLength(1); + const setUp = (servers: ServersMap = fallbackServers) => ({ + user: userEvent.setup(), + ...render(), }); - it('shows only create link when no servers exist yet', () => { - wrapped = shallow( - , - ); - const item = wrapped.find(DropdownItem); + it('contains the list of servers and the "mange servers" button', async () => { + const { user } = setUp(); - expect(item).toHaveLength(1); - expect(item.prop('to')).toEqual('/server/create'); - expect(item.find('span').text()).toContain('Add a server'); + await user.click(screen.getByText('Servers')); + const items = screen.getAllByRole('menuitem'); + expect(items).toHaveLength(values(fallbackServers).length + 1); + expect(items[0]).toHaveTextContent('foo'); + expect(items[1]).toHaveTextContent('bar'); + expect(items[2]).toHaveTextContent('baz'); + expect(items[3]).toHaveTextContent('Manage servers'); + }); + + it('contains a toggle with proper text', () => { + setUp(); + expect(screen.getByRole('link')).toHaveTextContent('Servers'); + }); + + it('contains a button to manage servers', async () => { + const { user } = setUp(); + + await user.click(screen.getByText('Servers')); + expect(screen.getByRole('menuitem', { name: 'Manage servers' })).toHaveAttribute('href', '/manage-servers'); + }); + + it('shows only create link when no servers exist yet', async () => { + const { user } = setUp({}); + + await user.click(screen.getByText('Servers')); + expect(screen.getByRole('menuitem')).toHaveTextContent('Add a server'); }); }); diff --git a/test/servers/__snapshots__/DeleteServerButton.test.tsx.snap b/test/servers/__snapshots__/DeleteServerButton.test.tsx.snap new file mode 100644 index 00000000..380ccbc4 --- /dev/null +++ b/test/servers/__snapshots__/DeleteServerButton.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders expected content 1`] = ` + + + Foo bar + + +`; + +exports[` renders expected content 2`] = ` + + + baz + + +`; + +exports[` renders expected content 3`] = ` + + + something + + +`; + +exports[` renders expected content 4`] = ` + + + + Remove this server + + +`;