diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7bf322..a8b49d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### 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. +* [#582](https://github.com/shlinkio/shlink-web-client/pull/582) Improved filtering short URLs by tag. + + Now, a new full tags selector component is available, which allows selecting any of the existing tags and also composes a toggle to filter by "any" tag or "all" tags. ### Changed * [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18. diff --git a/package-lock.json b/package-lock.json index a0b8bbff..6bcb9400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "react-copy-to-clipboard": "^5.0.4", "react-datepicker": "^4.7.0", "react-dom": "^18.1.0", - "react-external-link": "^1.2.2", + "react-external-link": "^2.0.0", "react-leaflet": "^4.0.0", "react-redux": "^8.0.0", "react-router-dom": "^6.3.0", @@ -19960,12 +19960,12 @@ "dev": true }, "node_modules/react-external-link": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.2.tgz", - "integrity": "sha512-CbJidnDmhcKlH5gVyt2dbmylcwayMY1wuRW8J1V1o7ZPMHdoUrDDmh/GvAMe847eI3sQBg7PLwSLAl5GiyuI+g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-2.0.0.tgz", + "integrity": "sha512-Q/Lso75l6OHOTvmhJ2YhnfN2f/0RJw76C4rEFkiiivNApNvCtyAFythdW4SpXHMPK6bbE8kk4j23+Zx+r1ImbA==", "peerDependencies": { - "react": "^17.0", - "react-dom": "^17.0" + "react": "^17.0 || ^18.0", + "react-dom": "^17.0 || ^18.0" } }, "node_modules/react-fast-compare": { @@ -41730,9 +41730,9 @@ "dev": true }, "react-external-link": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-1.2.2.tgz", - "integrity": "sha512-CbJidnDmhcKlH5gVyt2dbmylcwayMY1wuRW8J1V1o7ZPMHdoUrDDmh/GvAMe847eI3sQBg7PLwSLAl5GiyuI+g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-external-link/-/react-external-link-2.0.0.tgz", + "integrity": "sha512-Q/Lso75l6OHOTvmhJ2YhnfN2f/0RJw76C4rEFkiiivNApNvCtyAFythdW4SpXHMPK6bbE8kk4j23+Zx+r1ImbA==", "requires": {} }, "react-fast-compare": { diff --git a/package.json b/package.json index 7b2c39c8..93dcb97d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "react-copy-to-clipboard": "^5.0.4", "react-datepicker": "^4.7.0", "react-dom": "^18.1.0", - "react-external-link": "^1.2.2", + "react-external-link": "^2.0.0", "react-leaflet": "^4.0.0", "react-redux": "^8.0.0", "react-router-dom": "^6.3.0", diff --git a/src/common/react-tag-autocomplete.scss b/src/common/react-tag-autocomplete.scss index d410ac8d..8fbfb824 100644 --- a/src/common/react-tag-autocomplete.scss +++ b/src/common/react-tag-autocomplete.scss @@ -4,7 +4,7 @@ position: relative; padding: 5px 0 0 6px; border-radius: .3rem; - background-color: var(--input-color); + background-color: var(--primary-color); border: 1px solid var(--input-border-color); transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; @@ -16,6 +16,16 @@ cursor: text; } +.input-group > .react-tags { + flex: 1 1 auto; + width: 1%; + min-width: 0; +} + +.card .react-tags { + background-color: var(--input-color); +} + .react-tags.is-focused { box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); } @@ -76,7 +86,7 @@ font-size: 1.25rem; line-height: inherit; color: var(--input-text-color); - background-color: var(--input-color); + background-color: inherit; /* prevent autoresize overflowing the container */ max-width: 100%; @@ -88,6 +98,10 @@ outline: none; } +.react-tags__search-input::placeholder { + color: #6c757d; +} + .react-tags__search-input::-ms-clear { display: none; } diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 1b0ddfc0..3c149dd1 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -3,7 +3,7 @@ import Message from '../utils/Message'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; -import SearchField from '../utils/SearchField'; +import { SearchField } from '../utils/SearchField'; import { ShlinkDomainRedirects } from '../api/types'; import { SelectedServer } from '../servers/data'; import { DomainsList } from './reducers/domainsList'; diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 032fe203..53c364ce 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Link } from 'react-router-dom'; import { NoMenuLayout } from '../common/NoMenuLayout'; import { SimpleCard } from '../utils/SimpleCard'; -import SearchField from '../utils/SearchField'; +import { SearchField } from '../utils/SearchField'; import { Result } from '../utils/Result'; import { StateFlagTimeout } from '../utils/helpers/hooks'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; diff --git a/src/short-urls/ShortUrlsFilteringBar.scss b/src/short-urls/ShortUrlsFilteringBar.scss index 905210fd..32c75b20 100644 --- a/src/short-urls/ShortUrlsFilteringBar.scss +++ b/src/short-urls/ShortUrlsFilteringBar.scss @@ -1,3 +1,4 @@ .short-urls-filtering-bar__tags-icon { vertical-align: bottom; + font-size: 1.6rem; } diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 6b21b20b..205c62ef 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -1,24 +1,22 @@ import { FC } from 'react'; -import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEmpty, pipe } from 'ramda'; import { parseISO } from 'date-fns'; -import { Row } from 'reactstrap'; +import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTag, faTags } from '@fortawesome/free-solid-svg-icons'; import classNames from 'classnames'; -import SearchField from '../utils/SearchField'; -import Tag from '../tags/helpers/Tag'; +import { SearchField } from '../utils/SearchField'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { formatIsoDate } from '../utils/helpers/date'; -import ColorGenerator from '../utils/services/ColorGenerator'; import { DateRange } from '../utils/dates/types'; import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; -import { TooltipToggleSwitch } from '../utils/TooltipToggleSwitch'; import { OrderDir } from '../utils/helpers/ordering'; import { OrderingDropdown } from '../utils/OrderingDropdown'; import { useShortUrlsQuery } from './helpers/hooks'; import { SHORT_URLS_ORDERABLE_FIELDS, ShortUrlsOrder, ShortUrlsOrderableFields } from './data'; import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; +import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import './ShortUrlsFilteringBar.scss'; export interface ShortUrlsFilteringProps { @@ -31,9 +29,9 @@ export interface ShortUrlsFilteringProps { const dateOrNull = (date?: string) => (date ? parseISO(date) : null); -const ShortUrlsFilteringBar = ( - colorGenerator: ColorGenerator, +export const ShortUrlsFilteringBar = ( ExportShortUrlsBtn: FC, + TagsSelector: FC, ): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery(); const setDates = pipe( @@ -47,10 +45,7 @@ const ShortUrlsFilteringBar = ( (searchTerm: string) => (isEmpty(searchTerm) ? undefined : searchTerm), (searchTerm) => toFirstPage({ search: searchTerm }), ); - const removeTag = pipe( - (tag: string) => tags.filter((selectedTag) => selectedTag !== tag), - (updateTags) => toFirstPage({ tags: updateTags }), - ); + const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); const toggleTagsMode = pipe( () => (tagsMode === 'any' ? 'all' : 'any'), @@ -61,13 +56,21 @@ const ShortUrlsFilteringBar = (
- -
- -
-
- -
+ + + {canChangeTagsMode && tags.length > 1 && ( + <> + + + {tagsMode === 'all' ? 'With all the tags.' : 'With any of the tags.'} + + + )} + + +
+
+ +
+
+ +
- - {tags.length > 0 && ( -

- {canChangeTagsMode && tags.length > 1 && ( -
- - {tagsMode === 'all' ? 'Short URLs including all tags.' : 'Short URLs including any tag.'} - -
- )} - - {tags.map((tag) => - removeTag(tag)} />)} -

- )}
); }; - -export default ShortUrlsFilteringBar; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index f895bb72..3124ba93 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -1,5 +1,5 @@ import Bottle from 'bottlejs'; -import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar'; +import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar'; import ShortUrlsList from '../ShortUrlsList'; import ShortUrlsRow from '../helpers/ShortUrlsRow'; import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu'; @@ -50,7 +50,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader'); bottle.decorator('QrCodeModal', connect(['selectedServer'])); - bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator', 'ExportShortUrlsBtn'); + bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ExportShortUrlsBtn', 'TagsSelector'); bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter'); bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index d5ac0a00..ca8c3ca7 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react'; import { Row } from 'reactstrap'; import { pipe } from 'ramda'; import Message from '../utils/Message'; -import SearchField from '../utils/SearchField'; +import { SearchField } from '../utils/SearchField'; import { SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Result } from '../utils/Result'; diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index deb336d4..6f47b58f 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -10,6 +10,7 @@ export interface TagsSelectorProps { selectedTags: string[]; onChange: (tags: string[]) => void; placeholder?: string; + allowNew?: boolean; } interface TagsSelectorConnectProps extends TagsSelectorProps { @@ -21,7 +22,7 @@ interface TagsSelectorConnectProps extends TagsSelectorProps { const toComponentTag = (tag: string) => ({ id: tag, name: tag }); const TagsSelector = (colorGenerator: ColorGenerator) => ( - { selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps, + { selectedTags, onChange, placeholder, listTags, tagsList, settings, allowNew = true }: TagsSelectorConnectProps, ) => { useEffect(() => { listTags(); @@ -43,7 +44,7 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( tagComponent={ReactTagsTag} suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)} suggestionComponent={ReactTagsSuggestion} - allowNew + allowNew={allowNew} addOnBlur placeholderText={placeholder ?? 'Add tags to the URL'} minQueryLength={1} diff --git a/src/utils/ExportBtn.tsx b/src/utils/ExportBtn.tsx index 2a0a78c9..c6f141bb 100644 --- a/src/utils/ExportBtn.tsx +++ b/src/utils/ExportBtn.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { Button, ButtonProps } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faFileDownload } from '@fortawesome/free-solid-svg-icons'; +import { faFileCsv } from '@fortawesome/free-solid-svg-icons'; import { prettify } from './helpers/numbers'; interface ExportBtnProps extends Omit { @@ -11,6 +11,6 @@ interface ExportBtnProps extends Omit = ({ amount = 0, loading = false, ...rest }) => ( ); diff --git a/src/utils/OrderingDropdown.tsx b/src/utils/OrderingDropdown.tsx index 7b391c6a..71afb672 100644 --- a/src/utils/OrderingDropdown.tsx +++ b/src/utils/OrderingDropdown.tsx @@ -12,14 +12,14 @@ export interface OrderingDropdownProps { onChange: (orderField?: T, orderDir?: OrderDir) => void; isButton?: boolean; right?: boolean; + prefixed?: boolean; } export function OrderingDropdown( - { items, order, onChange, isButton = true, right = false }: OrderingDropdownProps, + { items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps, ) { const handleItemClick = (fieldKey: T) => () => { const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir); - onChange(newOrderDir ? fieldKey : undefined, newOrderDir); }; @@ -28,11 +28,14 @@ export function OrderingDropdown( {!isButton && <>Order by} - {isButton && !order.field && <>Order by...} - {isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`} + {isButton && !order.field && Order by...} + {isButton && order.field && <>{prefixed && 'Order by: '}{items[order.field]} - {order.dir ?? 'DESC'}} { +export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => { const [searchTerm, setSearchTerm] = useState(initialValue); const resetTimer = () => { @@ -55,5 +55,3 @@ const SearchField = ({ onChange, className, large = true, noBorder = false, init ); }; - -export default SearchField; diff --git a/src/utils/TooltipToggleSwitch.tsx b/src/utils/TooltipToggleSwitch.tsx deleted file mode 100644 index 35d2889b..00000000 --- a/src/utils/TooltipToggleSwitch.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { FC, PropsWithChildren, useRef } from 'react'; -import { UncontrolledTooltip, UncontrolledTooltipProps } from 'reactstrap'; -import { BooleanControlProps } from './BooleanControl'; -import ToggleSwitch from './ToggleSwitch'; - -export type TooltipToggleSwitchProps = BooleanControlProps & PropsWithChildren<{ - tooltip?: Omit; -}>; - -export const TooltipToggleSwitch: FC = ({ children, tooltip = {}, ...rest }) => { - const ref = useRef(); - - return ( - <> - { - ref.current = el ?? undefined; - }} - > - - - ref.current) as any} {...tooltip}>{children} - - ); -}; diff --git a/src/utils/dates/DateRangeRow.tsx b/src/utils/dates/DateRangeRow.tsx index 289d3b38..a3a2e917 100644 --- a/src/utils/dates/DateRangeRow.tsx +++ b/src/utils/dates/DateRangeRow.tsx @@ -1,3 +1,4 @@ +import { endOfDay } from 'date-fns'; import DateInput from '../DateInput'; import { DateRange } from './types'; @@ -29,7 +30,7 @@ const DateRangeRow = ( isClearable minDate={startDate ?? undefined} disabled={disabled} - onChange={onEndDateChange} + onChange={(date) => onEndDateChange(date && endOfDay(date))} /> diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index b6bfd5ca..28ca9c61 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -5,7 +5,7 @@ import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-soli import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { UncontrolledTooltip } from 'reactstrap'; import { SimplePaginator } from '../common/SimplePaginator'; -import SearchField from '../utils/SearchField'; +import { SearchField } from '../utils/SearchField'; import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering'; import { prettify } from '../utils/helpers/numbers'; import { supportsBotVisits } from '../utils/helpers/features'; diff --git a/test/servers/ManageServers.test.tsx b/test/servers/ManageServers.test.tsx index c3940678..49292a1e 100644 --- a/test/servers/ManageServers.test.tsx +++ b/test/servers/ManageServers.test.tsx @@ -4,7 +4,7 @@ import { Button } from 'reactstrap'; import ServersExporter from '../../src/servers/services/ServersExporter'; import { ManageServers as createManageServers } from '../../src/servers/ManageServers'; import { ServersMap, ServerWithId } from '../../src/servers/data'; -import SearchField from '../../src/utils/SearchField'; +import { SearchField } from '../../src/utils/SearchField'; import { Result } from '../../src/utils/Result'; describe('', () => { diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index d7307aca..113196cf 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -1,156 +1,136 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Mock } from 'ts-mockery'; -import { formatISO } from 'date-fns'; -import { useLocation, useNavigate } from 'react-router-dom'; -import filteringBarCreator from '../../src/short-urls/ShortUrlsFilteringBar'; -import SearchField from '../../src/utils/SearchField'; -import Tag from '../../src/tags/helpers/Tag'; -import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector'; -import ColorGenerator from '../../src/utils/services/ColorGenerator'; +import { endOfDay, formatISO, startOfDay } from 'date-fns'; +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; +import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar'; import { ReachableServer, SelectedServer } from '../../src/servers/data'; -import { TooltipToggleSwitch } from '../../src/utils/TooltipToggleSwitch'; -import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; +import { DateRange } from '../../src/utils/dates/types'; +import { formatDate } from '../../src/utils/helpers/date'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useNavigate: jest.fn(), useParams: jest.fn().mockReturnValue({ serverId: '1' }), + useNavigate: jest.fn(), useLocation: jest.fn().mockReturnValue({}), })); describe('', () => { - let wrapper: ShallowWrapper; - const ExportShortUrlsBtn = () => null; - const ShortUrlsFilteringBar = filteringBarCreator(Mock.all(), ExportShortUrlsBtn); + const ShortUrlsFilteringBar = filteringBarCreator(() => <>ExportShortUrlsBtn, () => <>TagsSelector); const navigate = jest.fn(); const handleOrderBy = jest.fn(); const now = new Date(); - const createWrapper = (search = '', selectedServer?: SelectedServer) => { + const setUp = (search = '', selectedServer?: SelectedServer) => { (useLocation as any).mockReturnValue({ search }); (useNavigate as any).mockReturnValue(navigate); - wrapper = shallow( - ()} - order={{}} - handleOrderBy={handleOrderBy} - />, - ); - - return wrapper; + return { + user: userEvent.setup(), + ...render( + + ()} + order={{}} + handleOrderBy={handleOrderBy} + /> + , + ), + }; }; afterEach(jest.clearAllMocks); - afterEach(() => wrapper?.unmount()); it('renders expected children components', () => { - const wrapper = createWrapper(); + setUp(); - expect(wrapper.find(SearchField)).toHaveLength(1); - expect(wrapper.find(DateRangeSelector)).toHaveLength(1); - expect(wrapper.find(OrderingDropdown)).toHaveLength(1); - expect(wrapper.find(ExportShortUrlsBtn)).toHaveLength(1); + expect(screen.getByText('ExportShortUrlsBtn')).toBeInTheDocument(); + expect(screen.getByText('TagsSelector')).toBeInTheDocument(); + }); + + it('redirects to first page when search field changes', async () => { + const { user } = setUp(); + + expect(navigate).not.toHaveBeenCalled(); + await user.type(screen.getByPlaceholderText('Search...'), 'search-term'); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term')); }); it.each([ - ['tags=foo,bar,baz', 3], - ['tags=foo,baz', 2], - ['', 0], - ['foo=bar', 0], - ])('renders the proper amount of tags', (search, expectedTagComps) => { - const wrapper = createWrapper(search); - - expect(wrapper.find(Tag)).toHaveLength(expectedTagComps); - }); - - it('redirects to first page when search field changes', () => { - const wrapper = createWrapper(); - const searchField = wrapper.find(SearchField); - - expect(navigate).not.toHaveBeenCalled(); - searchField.simulate('change', 'search-term'); - expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term'); - }); - - it('redirects to first page when a tag is removed', () => { - const wrapper = createWrapper('tags=foo,bar'); - const tag = wrapper.find(Tag).first(); - - expect(navigate).not.toHaveBeenCalled(); - tag.simulate('close'); - expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?tags=bar'); - }); - - it.each([ - [{ startDate: now }, `startDate=${encodeURIComponent(formatISO(now))}`], - [{ endDate: now }, `endDate=${encodeURIComponent(formatISO(now))}`], + [{ startDate: now } as DateRange, `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}`], + [{ endDate: now } as DateRange, `endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`], [ - { startDate: now, endDate: now }, - `startDate=${encodeURIComponent(formatISO(now))}&endDate=${encodeURIComponent(formatISO(now))}`, + { startDate: now, endDate: now } as DateRange, + `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}&endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`, ], - ])('redirects to first page when date range changes', (dates, expectedQuery) => { - const wrapper = createWrapper(); - const dateRange = wrapper.find(DateRangeSelector); + ])('redirects to first page when date range changes', async (dates, expectedQuery) => { + const { user } = setUp(); + + await user.click(screen.getByRole('button', { name: 'All short URLs' })); + expect(await screen.findByRole('menu')).toBeInTheDocument(); expect(navigate).not.toHaveBeenCalled(); - dateRange.simulate('datesChange', dates); - expect(navigate).toHaveBeenCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`); + dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatDate()(dates.startDate) ?? ''); + dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatDate()(dates.endDate) ?? ''); + expect(navigate).toHaveBeenLastCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`); }); it.each([ - ['tags=foo,bar,baz', Mock.of({ version: '3.0.0' }), 1], - ['tags=foo,bar', Mock.of({ version: '3.1.0' }), 1], - ['tags=foo', Mock.of({ version: '3.0.0' }), 0], - ['', Mock.of({ version: '3.0.0' }), 0], - ['tags=foo,bar,baz', Mock.of({ version: '2.10.0' }), 0], - ['', Mock.of({ version: '2.10.0' }), 0], + ['tags=foo,bar,baz', Mock.of({ version: '3.0.0' }), true], + ['tags=foo,bar', Mock.of({ version: '3.1.0' }), true], + ['tags=foo', Mock.of({ version: '3.0.0' }), false], + ['', Mock.of({ version: '3.0.0' }), false], + ['tags=foo,bar,baz', Mock.of({ version: '2.10.0' }), false], + ['', Mock.of({ version: '2.10.0' }), false], ])( 'renders tags mode toggle if the server supports it and there is more than one tag selected', - (search, selectedServer, expectedTagToggleComponents) => { - const wrapper = createWrapper(search, selectedServer); - const toggle = wrapper.find(TooltipToggleSwitch); + (search, selectedServer, shouldHaveComponent) => { + setUp(search, selectedServer); - expect(toggle).toHaveLength(expectedTagToggleComponents); + if (shouldHaveComponent) { + expect(screen.getByLabelText('Change tags mode')).toBeInTheDocument(); + } else { + expect(screen.queryByLabelText('Change tags mode')).not.toBeInTheDocument(); + } }, ); it.each([ - ['', 'Short URLs including any tag.', false], - ['&tagsMode=all', 'Short URLs including all tags.', true], - ['&tagsMode=any', 'Short URLs including any tag.', false], - ])('expected tags mode tooltip title', (initialTagsMode, expectedToggleText, expectedChecked) => { - const wrapper = createWrapper(`tags=foo,bar${initialTagsMode}`, Mock.of({ version: '3.0.0' })); - const toggle = wrapper.find(TooltipToggleSwitch); + ['', 'With any of the tags.'], + ['&tagsMode=all', 'With all the tags.'], + ['&tagsMode=any', 'With any of the tags.'], + ])('expected tags mode tooltip title', async (initialTagsMode, expectedToggleText) => { + const { user } = setUp(`tags=foo,bar${initialTagsMode}`, Mock.of({ version: '3.0.0' })); - expect(toggle.prop('children')).toEqual(expectedToggleText); - expect(toggle.prop('checked')).toEqual(expectedChecked); + await user.hover(screen.getByLabelText('Change tags mode')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(expectedToggleText); }); it.each([ ['', 'tagsMode=all'], ['&tagsMode=all', 'tagsMode=any'], ['&tagsMode=any', 'tagsMode=all'], - ])('redirects to first page when tags mode changes', (initialTagsMode, expectedRedirectTagsMode) => { - const wrapper = createWrapper(`tags=foo,bar${initialTagsMode}`, Mock.of({ version: '3.0.0' })); - const toggle = wrapper.find(TooltipToggleSwitch); + ])('redirects to first page when tags mode changes', async (initialTagsMode, expectedRedirectTagsMode) => { + const { user } = setUp(`tags=foo,bar${initialTagsMode}`, Mock.of({ version: '3.0.0' })); expect(navigate).not.toHaveBeenCalled(); - toggle.simulate('change'); + await user.click(screen.getByLabelText('Change tags mode')); expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); }); - it('handles order through dropdown', () => { - const wrapper = createWrapper(); + it('handles order through dropdown', async () => { + const { user } = setUp(); + const clickMenuItem = async (name: string | RegExp) => { + await user.click(screen.getByRole('button', { name: 'Order by...' })); + await user.click(await screen.findByRole('menuitem', { name })); + }; - expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); + await clickMenuItem(/^Short URL/); + expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'ASC'); - wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); - expect(handleOrderBy).toHaveBeenCalledWith('visits', 'ASC'); + await clickMenuItem(/^Title/); + expect(handleOrderBy).toHaveBeenCalledWith('title', 'ASC'); - wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC'); - expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'DESC'); - - wrapper.find(OrderingDropdown).simulate('change', undefined, undefined); - expect(handleOrderBy).toHaveBeenCalledWith(undefined, undefined); + await clickMenuItem(/^Long URL/); + expect(handleOrderBy).toHaveBeenCalledWith('longUrl', 'ASC'); }); }); diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index 82c441a4..d642a060 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -7,7 +7,7 @@ import { TagsList } from '../../src/tags/reducers/tagsList'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { Result } from '../../src/utils/Result'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; -import SearchField from '../../src/utils/SearchField'; +import { SearchField } from '../../src/utils/SearchField'; import { Settings } from '../../src/settings/reducers/settings'; import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps'; import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; diff --git a/test/utils/ExportBtn.test.tsx b/test/utils/ExportBtn.test.tsx index 03f27420..0ed10837 100644 --- a/test/utils/ExportBtn.test.tsx +++ b/test/utils/ExportBtn.test.tsx @@ -1,29 +1,22 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faFileDownload } from '@fortawesome/free-solid-svg-icons'; +import { render, screen } from '@testing-library/react'; import { ExportBtn } from '../../src/utils/ExportBtn'; describe('', () => { - let wrapper: ShallowWrapper; - const createWrapper = (amount?: number, loading = false) => { - wrapper = shallow(); - - return wrapper; - }; - - afterEach(jest.clearAllMocks); - afterEach(() => wrapper?.unmount()); + const setUp = (amount?: number, loading = false) => render(); it.each([ [true, 'Exporting...'], - [false, 'Export ('], - ])('renders a button', (loading, text) => { - const wrapper = createWrapper(undefined, loading); + [false, 'Export (0)'], + ])('renders loading state when expected', async (loading, text) => { + setUp(undefined, loading); + const btn = await screen.findByRole('button'); - expect(wrapper.prop('outline')).toEqual(true); - expect(wrapper.prop('color')).toEqual('primary'); - expect(wrapper.prop('disabled')).toEqual(loading); - expect(wrapper.html()).toContain(text); + expect(btn).toHaveTextContent(text); + if (loading) { + expect(btn).toHaveAttribute('disabled'); + } else { + expect(btn).not.toHaveAttribute('disabled'); + } }); it.each([ @@ -31,17 +24,13 @@ describe('', () => { [10, '10'], [10_000, '10,000'], [10_000_000, '10,000,000'], - ])('renders expected amount', (amount, expectedRenderedAmount) => { - const wrapper = createWrapper(amount); - - expect(wrapper.html()).toContain(`Export (${expectedRenderedAmount})`); + ])('renders expected amount', async (amount, expectedRenderedAmount) => { + setUp(amount); + expect(await screen.findByRole('button')).toHaveTextContent(`Export (${expectedRenderedAmount})`); }); it('renders expected icon', () => { - const wrapper = createWrapper(); - const icon = wrapper.find(FontAwesomeIcon); - - expect(icon).toHaveLength(1); - expect(icon.prop('icon')).toEqual(faFileDownload); + setUp(); + expect(screen.getByRole('img', { hidden: true })).toMatchSnapshot(); }); }); diff --git a/test/utils/OrderingDropdown.test.tsx b/test/utils/OrderingDropdown.test.tsx new file mode 100644 index 00000000..3c978e1a --- /dev/null +++ b/test/utils/OrderingDropdown.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { values } from 'ramda'; +import { OrderingDropdown, OrderingDropdownProps } from '../../src/utils/OrderingDropdown'; +import { OrderDir } from '../../src/utils/helpers/ordering'; + +describe('', () => { + const items = { + foo: 'Foo', + bar: 'Bar', + baz: 'Hello World', + }; + const setUp = (props: Partial = {}) => ({ + user: userEvent.setup(), + ...render(), + }); + const setUpWithDisplayedMenu = async (props: Partial = {}) => { + const result = setUp(props); + const { user } = result; + + await user.click(screen.getByRole('button')); + expect(await screen.findByRole('menu')).toBeInTheDocument(); + + return result; + }; + + it('properly renders provided list of items', async () => { + await setUpWithDisplayedMenu(); + + const dropdownItems = screen.getAllByRole('menuitem'); + + expect(dropdownItems).toHaveLength(values(items).length); + expect(dropdownItems[0]).toHaveTextContent('Foo'); + expect(dropdownItems[1]).toHaveTextContent('Bar'); + expect(dropdownItems[2]).toHaveTextContent('Hello World'); + expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument(); + }); + + it.each([ + ['foo', 0], + ['bar', 1], + ['baz', 2], + ])('properly marks selected field as active with proper icon', async (field, expectedActiveIndex) => { + await setUpWithDisplayedMenu({ order: { field, dir: 'DESC' } }); + + const dropdownItems = screen.getAllByRole('menuitem'); + + expect(dropdownItems).toHaveLength(4); + expect(screen.queryByRole('button', { name: 'Clear selection' })).not.toBeInTheDocument(); + + dropdownItems.forEach((item, index) => { + if (index === expectedActiveIndex) { + expect(item).toHaveAttribute('class', expect.stringContaining('active')); + } else { + expect(item).not.toHaveAttribute('class', expect.stringContaining('active')); + } + }); + }); + + it.each([ + [{} as any, 'foo', 'ASC'], + [{ field: 'baz', dir: 'ASC' } as any, 'foo', 'ASC'], + [{ field: 'foo', dir: 'ASC' } as any, 'foo', 'DESC'], + [{ field: 'foo', dir: 'DESC' } as any, undefined, undefined], + ])( + 'triggers change with proper params depending on clicked item and initial state', + async (initialOrder, expectedNewField, expectedNewDir) => { + const onChange = jest.fn(); + const { user } = await setUpWithDisplayedMenu({ onChange, order: initialOrder }); + + await user.click(screen.getAllByRole('menuitem')[0]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(expectedNewField, expectedNewDir); + }, + ); + + it('clears selection when last item is clicked', async () => { + const onChange = jest.fn(); + const { user } = await setUpWithDisplayedMenu({ onChange, order: { field: 'baz', dir: 'ASC' } }); + + await user.click(screen.getAllByRole('menuitem')[3]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(); + }); + + it.each([ + [{ isButton: false }, /Order by$/], + [{ isButton: true }, 'Order by...'], + [ + { isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } }, + 'Order by: Foo - ASC', + ], + [ + { isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } }, + 'Order by: Hello World - DESC', + ], + [{ isButton: true, order: { field: 'baz' } }, 'Order by: Hello World - DESC'], + [ + { isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir }, prefixed: false }, + /^Hello World - DESC/, + ], + ])('with %s props displays %s in toggle', async (props, expectedText) => { + setUp(props); + expect(screen.getByRole('button')).toHaveTextContent(expectedText); + }); +}); diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx deleted file mode 100644 index aff01c1a..00000000 --- a/test/utils/SortingDropdown.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { DropdownItem, DropdownToggle } from 'reactstrap'; -import { identity, values } from 'ramda'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons'; -import { OrderingDropdown, OrderingDropdownProps } from '../../src/utils/OrderingDropdown'; -import { OrderDir } from '../../src/utils/helpers/ordering'; - -describe('', () => { - let wrapper: ShallowWrapper; - const items = { - foo: 'Foo', - bar: 'Bar', - baz: 'Hello World', - }; - const createWrapper = (props: Partial = {}) => { - wrapper = shallow(); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); - - it('properly renders provided list of items', () => { - const wrapper = createWrapper(); - const dropdownItems = wrapper.find(DropdownItem); - const secondIndex = 2; - const clearItemsCount = 2; - - expect(dropdownItems).toHaveLength(values(items).length + clearItemsCount); - expect(dropdownItems.at(0).html()).toContain('Foo'); - expect(dropdownItems.at(1).html()).toContain('Bar'); - expect(dropdownItems.at(secondIndex).html()).toContain('Hello World'); - }); - - it('properly marks selected field as active with proper icon', () => { - const wrapper = createWrapper({ order: { field: 'bar', dir: 'DESC' } }); - const activeItem = wrapper.find('DropdownItem[active=true]'); - const activeItemIcon = activeItem.first().find(FontAwesomeIcon); - - expect(activeItem).toHaveLength(1); - expect(activeItemIcon.prop('icon')).toEqual(caretDownIcon); - }); - - it('triggers change function when item is clicked and no order field was provided', () => { - const onChange = jest.fn(); - const wrapper = createWrapper({ onChange }); - const firstItem = wrapper.find(DropdownItem).first(); - - firstItem.simulate('click'); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith('foo', 'ASC'); - }); - - it('triggers change function when item is clicked and an order field was provided', () => { - const onChange = jest.fn(); - const wrapper = createWrapper({ onChange, order: { field: 'baz', dir: 'ASC' } }); - const firstItem = wrapper.find(DropdownItem).first(); - - firstItem.simulate('click'); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith('foo', 'ASC'); - }); - - it('updates order dir when already selected item is clicked', () => { - const onChange = jest.fn(); - const wrapper = createWrapper({ onChange, order: { field: 'foo', dir: 'ASC' } }); - const firstItem = wrapper.find(DropdownItem).first(); - - firstItem.simulate('click'); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith('foo', 'DESC'); - }); - - it.each([ - [{ isButton: false }, <>Order by], - [{ isButton: true }, <>Order by...], - [ - { isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } }, - 'Order by: "Foo" - "ASC"', - ], - [ - { isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } }, - 'Order by: "Hello World" - "DESC"', - ], - [{ isButton: true, order: { field: 'baz' } }, 'Order by: "Hello World" - "DESC"'], - ])('displays expected text in toggle', (props, expectedText) => { - const wrapper = createWrapper(props); - const toggle = wrapper.find(DropdownToggle); - const [children] = (toggle.prop('children') as any[]).filter(Boolean); - - expect(children).toEqual(expectedText); - }); -}); diff --git a/test/utils/TooltipToggleSwitch.test.tsx b/test/utils/TooltipToggleSwitch.test.tsx deleted file mode 100644 index 6d619fb3..00000000 --- a/test/utils/TooltipToggleSwitch.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { PropsWithChildren } from 'react'; -import { UncontrolledTooltip } from 'reactstrap'; -import { TooltipToggleSwitch, TooltipToggleSwitchProps } from '../../src/utils/TooltipToggleSwitch'; -import ToggleSwitch from '../../src/utils/ToggleSwitch'; - -describe('', () => { - let wrapper: ShallowWrapper; - const createWrapper = (props: PropsWithChildren = {}) => { - wrapper = shallow(); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); - - it.each([ - ['foo'], - ['bar'], - ['baz'], - ])('shows children inside tooltip', (children) => { - const wrapper = createWrapper({ children }); - const tooltip = wrapper.find(UncontrolledTooltip); - - expect(tooltip.prop('children')).toEqual(children); - }); - - it('properly propagates corresponding props to every component', () => { - const expectedTooltipProps = { placement: 'left', delay: 30 }; - const expectedToggleProps = { checked: true, className: 'foo' }; - const wrapper = createWrapper({ tooltip: expectedTooltipProps, ...expectedToggleProps }); - const tooltip = wrapper.find(UncontrolledTooltip); - const toggle = wrapper.find(ToggleSwitch); - - expect(tooltip.props()).toEqual(expect.objectContaining(expectedTooltipProps)); - expect(toggle.props()).toEqual(expect.objectContaining(expectedToggleProps)); - }); -}); diff --git a/test/utils/__snapshots__/ExportBtn.test.tsx.snap b/test/utils/__snapshots__/ExportBtn.test.tsx.snap new file mode 100644 index 00000000..7484fe5b --- /dev/null +++ b/test/utils/__snapshots__/ExportBtn.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders expected icon 1`] = ` + +`; diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index c4ddac6a..279ada47 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery'; import VisitsTable, { VisitsTableProps } from '../../src/visits/VisitsTable'; import { rangeOf } from '../../src/utils/utils'; import { SimplePaginator } from '../../src/common/SimplePaginator'; -import SearchField from '../../src/utils/SearchField'; +import { SearchField } from '../../src/utils/SearchField'; import { NormalizedVisit } from '../../src/visits/types'; import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { SemVer } from '../../src/utils/helpers/version';