From 0312a0911cdbf456f6baf7144f2d6eff1faac164 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Dec 2022 10:54:18 +0100 Subject: [PATCH 01/56] Fixed build badge in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ffdaf2b1b..9456314ed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # shlink-web-client -[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22) +[![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink-web-client/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22) [![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client) [![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/) From 37caa1ad19f90d20e6b59dd7db163f2894d6c80f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Dec 2022 19:56:58 +0100 Subject: [PATCH 02/56] Removed references to tags cards --- src/settings/TagsSettings.tsx | 11 ---- src/tags/TagCard.scss | 38 ------------ src/tags/TagCard.tsx | 89 ---------------------------- src/tags/TagsCards.tsx | 32 ---------- src/tags/TagsList.tsx | 36 ++++------- src/tags/TagsModeDropdown.tsx | 23 ------- src/tags/services/provideServices.ts | 7 +-- test/settings/TagsSettings.test.tsx | 28 +-------- test/tags/TagCard.test.tsx | 57 ------------------ test/tags/TagsCards.test.tsx | 26 -------- test/tags/TagsList.test.tsx | 15 +---- test/tags/TagsModeDropdown.test.tsx | 34 ----------- 12 files changed, 15 insertions(+), 381 deletions(-) delete mode 100644 src/tags/TagCard.scss delete mode 100644 src/tags/TagCard.tsx delete mode 100644 src/tags/TagsCards.tsx delete mode 100644 src/tags/TagsModeDropdown.tsx delete mode 100644 test/tags/TagCard.test.tsx delete mode 100644 test/tags/TagsCards.test.tsx delete mode 100644 test/tags/TagsModeDropdown.test.tsx diff --git a/src/settings/TagsSettings.tsx b/src/settings/TagsSettings.tsx index db37e1673..8cb5631d7 100644 --- a/src/settings/TagsSettings.tsx +++ b/src/settings/TagsSettings.tsx @@ -1,10 +1,7 @@ import { FC } from 'react'; import { SimpleCard } from '../utils/SimpleCard'; -import { TagsModeDropdown } from '../tags/TagsModeDropdown'; -import { capitalize } from '../utils/utils'; import { OrderingDropdown } from '../utils/OrderingDropdown'; import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; -import { FormText } from '../utils/forms/FormText'; import { LabeledFormGroup } from '../utils/forms/LabeledFormGroup'; import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings'; @@ -15,14 +12,6 @@ interface TagsProps { export const TagsSettings: FC = ({ settings: { tags }, setTagsSettings }) => ( - - capitalize(tagsMode)} - onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })} - /> - Tags will be displayed as {tags?.defaultMode ?? 'cards'}. - void; -} - -const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth; - -export const TagCard = ( - DeleteTagConfirmModal: FC, - EditTagModal: FC, - colorGenerator: ColorGenerator, -) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => { - const [isDeleteModalOpen, toggleDelete] = useToggle(); - const [isEditModalOpen, toggleEdit] = useToggle(); - const [hasTitle,, displayTitle] = useToggle(); - const titleRef = useRef(); - const serverId = getServerId(selectedServer); - - useEffect(() => { - if (isTruncated(titleRef.current)) { - displayTitle(); - } - }, [titleRef.current]); - - return ( - - - - -
- - {tag.tag} -
-
- - - - - Short URLs - {prettify(tag.shortUrls)} - - - Visits - {prettify(tag.visits)} - - - - - - -
- ); -}; diff --git a/src/tags/TagsCards.tsx b/src/tags/TagsCards.tsx deleted file mode 100644 index 95917540d..000000000 --- a/src/tags/TagsCards.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { FC, useState } from 'react'; -import { splitEvery } from 'ramda'; -import { Row } from 'reactstrap'; -import { TagCardProps } from './TagCard'; -import { TagsListChildrenProps } from './data/TagsListChildrenProps'; - -const { ceil } = Math; -const TAGS_GROUPS_AMOUNT = 4; - -export const TagsCards = (TagCard: FC): FC => ({ sortedTags, selectedServer }) => { - const [displayedTag, setDisplayedTag] = useState(); - const tagsCount = sortedTags.length; - const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags); - - return ( - - {tagsGroups.map((group, index) => ( -
- {group.map((tag) => ( - setDisplayedTag(displayedTag !== tag.tag ? tag.tag : undefined)} - /> - ))} -
- ))} -
- ); -}; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index 2c8259313..be35f1539 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -8,17 +8,11 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { Topics } from '../mercure/helpers/Topics'; -import { Settings, TagsMode } from '../settings/reducers/settings'; +import { Settings } from '../settings/reducers/settings'; import { determineOrderDir, sortList } from '../utils/helpers/ordering'; import { OrderingDropdown } from '../utils/OrderingDropdown'; import { TagsList as TagsListState } from './reducers/tagsList'; -import { - TagsOrderableFields, - TAGS_ORDERABLE_FIELDS, - TagsListChildrenProps, - TagsOrder, -} from './data/TagsListChildrenProps'; -import { TagsModeDropdown } from './TagsModeDropdown'; +import { TagsOrderableFields, TAGS_ORDERABLE_FIELDS, TagsOrder } from './data/TagsListChildrenProps'; import { NormalizedTag } from './data'; import { TagsTableProps } from './TagsTable'; @@ -30,10 +24,9 @@ export interface TagsListProps { settings: Settings; } -export const TagsList = (TagsCards: FC, TagsTable: FC) => boundToMercureHub(( +export const TagsList = (TagsTable: FC) => boundToMercureHub(( { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps, ) => { - const [mode, setMode] = useState(settings.tags?.defaultMode ?? 'cards'); const [order, setOrder] = useState(settings.tags?.defaultOrdering ?? {}); const resolveSortedTags = pipe( () => tagsList.filteredTags.map((tag): NormalizedTag => ({ @@ -73,26 +66,21 @@ export const TagsList = (TagsCards: FC, TagsTable: FC - : ( - - ); + return ( + + ); }; return ( <> -
- -
-
+
void; - renderTitle?: (mode: TagsMode) => string; -} - -export const TagsModeDropdown: FC = ({ mode, onChange, renderTitle }) => ( - - onChange('cards')}> - Cards - - onChange('list')}> - List - - -); diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index 2d81afb78..0fbda139b 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -1,7 +1,6 @@ import { prop } from 'ramda'; import Bottle, { IContainer } from 'bottlejs'; import { TagsSelector } from '../helpers/TagsSelector'; -import { TagCard } from '../TagCard'; import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal'; import { EditTagModal } from '../helpers/EditTagModal'; import { TagsList } from '../TagsList'; @@ -9,7 +8,6 @@ import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsLi import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete'; import { editTag, tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { ConnectDecorator } from '../../container/types'; -import { TagsCards } from '../TagsCards'; import { TagsTable } from '../TagsTable'; import { TagsTableRow } from '../TagsTableRow'; @@ -18,20 +16,17 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); bottle.decorator('TagsSelector', connect(['tagsList', 'settings'], ['listTags'])); - bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); - bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal); bottle.decorator('DeleteTagConfirmModal', connect(['tagDelete'], ['deleteTag', 'tagDeleted'])); bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator'); bottle.decorator('EditTagModal', connect(['tagEdit'], ['editTag', 'tagEdited'])); - bottle.serviceFactory('TagsCards', TagsCards, 'TagCard'); bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow'); - bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable'); + bottle.serviceFactory('TagsList', TagsList, 'TagsTable'); bottle.decorator('TagsList', connect( ['tagsList', 'selectedServer', 'mercureInfo', 'settings'], ['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'], diff --git a/test/settings/TagsSettings.test.tsx b/test/settings/TagsSettings.test.tsx index 854ebf9fe..d1b56f056 100644 --- a/test/settings/TagsSettings.test.tsx +++ b/test/settings/TagsSettings.test.tsx @@ -1,9 +1,8 @@ import { screen } from '@testing-library/react'; import { Mock } from 'ts-mockery'; -import { Settings, TagsMode, TagsSettings as TagsSettingsOptions } from '../../src/settings/reducers/settings'; +import { Settings, TagsSettings as TagsSettingsOptions } from '../../src/settings/reducers/settings'; import { TagsSettings } from '../../src/settings/TagsSettings'; import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps'; -import { capitalize } from '../../src/utils/utils'; import { renderWithEvents } from '../__helpers__/setUpTest'; describe('', () => { @@ -17,35 +16,10 @@ describe('', () => { it('renders expected amount of groups', () => { setUp(); - expect(screen.getByText('Default display mode when managing tags:')).toBeInTheDocument(); expect(screen.getByText('Default ordering for tags list:')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Order by...' })).toBeInTheDocument(); }); - it.each([ - [undefined, 'cards'], - [{}, 'cards'], - [{ defaultMode: 'cards' as TagsMode }, 'cards'], - [{ defaultMode: 'list' as TagsMode }, 'list'], - ])('shows expected tags displaying mode', (tags, expectedMode) => { - const { container } = setUp(tags); - - expect(screen.getByRole('button', { name: capitalize(expectedMode) })).toBeInTheDocument(); - expect(container.querySelector('.form-text')).toHaveTextContent(`Tags will be displayed as ${expectedMode}.`); - }); - - it.each([ - ['cards' as TagsMode], - ['list' as TagsMode], - ])('invokes setTagsSettings when tags mode changes', async (defaultMode) => { - const { user } = setUp(); - - expect(setTagsSettings).not.toHaveBeenCalled(); - await user.click(screen.getByText('List')); - await user.click(screen.getByRole('menuitem', { name: capitalize(defaultMode) })); - expect(setTagsSettings).toHaveBeenCalledWith({ defaultMode }); - }); - it.each([ [undefined, 'Order by...'], [{}, 'Order by...'], diff --git a/test/tags/TagCard.test.tsx b/test/tags/TagCard.test.tsx deleted file mode 100644 index 18dcb66c6..000000000 --- a/test/tags/TagCard.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { Mock } from 'ts-mockery'; -import { TagCard as createTagCard } from '../../src/tags/TagCard'; -import { ReachableServer } from '../../src/servers/data'; -import { renderWithEvents } from '../__helpers__/setUpTest'; -import { colorGeneratorMock } from '../utils/services/__mocks__/ColorGenerator.mock'; - -describe('', () => { - const TagCard = createTagCard( - ({ isOpen }) => DeleteTagConfirmModal {isOpen ? '[Open]' : '[Closed]'}, - ({ isOpen }) => EditTagModal {isOpen ? '[Open]' : '[Closed]'}, - colorGeneratorMock, - ); - const setUp = (tag = 'ssr') => renderWithEvents( - - ({ id: '1' })} - displayed - toggle={() => {}} - /> - , - ); - - afterEach(jest.resetAllMocks); - - it.each([ - ['ssr', '/server/1/list-short-urls/1?tags=ssr', '/server/1/tag/ssr/visits'], - ['ssr-&-foo', '/server/1/list-short-urls/1?tags=ssr-%26-foo', '/server/1/tag/ssr-&-foo/visits'], - ])('shows expected links for provided tags', (tag, shortUrlsLink, visitsLink) => { - setUp(tag); - - expect(screen.getByText('48').parentNode).toHaveAttribute('href', shortUrlsLink); - expect(screen.getByText('23,257').parentNode).toHaveAttribute('href', visitsLink); - }); - - it('displays delete modal when delete btn is clicked', async () => { - const { user } = setUp(); - - expect(screen.getByText(/^DeleteTagConfirmModal/)).not.toHaveTextContent('[Open]'); - expect(screen.getByText(/^DeleteTagConfirmModal/)).toHaveTextContent('[Closed]'); - await user.click(screen.getByLabelText('Delete tag')); - expect(screen.getByText(/^DeleteTagConfirmModal/)).toHaveTextContent('[Open]'); - expect(screen.getByText(/^DeleteTagConfirmModal/)).not.toHaveTextContent('[Closed]'); - }); - - it('displays edit modal when edit btn is clicked', async () => { - const { user } = setUp(); - - expect(screen.getByText(/^EditTagModal/)).not.toHaveTextContent('[Open]'); - expect(screen.getByText(/^EditTagModal/)).toHaveTextContent('[Closed]'); - await user.click(screen.getByLabelText('Edit tag')); - expect(screen.getByText(/^EditTagModal/)).toHaveTextContent('[Open]'); - expect(screen.getByText(/^EditTagModal/)).not.toHaveTextContent('[Closed]'); - }); -}); diff --git a/test/tags/TagsCards.test.tsx b/test/tags/TagsCards.test.tsx deleted file mode 100644 index 36d6f2863..000000000 --- a/test/tags/TagsCards.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { screen } from '@testing-library/react'; -import { Mock } from 'ts-mockery'; -import { TagsCards as createTagsCards } from '../../src/tags/TagsCards'; -import { SelectedServer } from '../../src/servers/data'; -import { rangeOf } from '../../src/utils/utils'; -import { NormalizedTag } from '../../src/tags/data'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const amountOfTags = 10; - const sortedTags = rangeOf(amountOfTags, (i) => Mock.of({ tag: `tag_${i}` })); - const TagsCards = createTagsCards(() => TagCard); - const setUp = () => renderWithEvents( - ()} />, - ); - - it('renders the proper amount of groups and cards based on the amount of tags', () => { - const { container } = setUp(); - const amountOfGroups = 4; - const cards = screen.getAllByText('TagCard'); - const groups = container.querySelectorAll('.col-md-6'); - - expect(cards).toHaveLength(amountOfTags); - expect(groups).toHaveLength(amountOfGroups); - }); -}); diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index 03caedcd9..9e47ffcbc 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -9,7 +9,7 @@ import { renderWithEvents } from '../__helpers__/setUpTest'; describe('', () => { const filterTags = jest.fn(); - const TagsListComp = createTagsList(() => <>TagsCards, () => <>TagsTable); + const TagsListComp = createTagsList(() => <>TagsTable); const setUp = (tagsList: Partial) => renderWithEvents( ()} @@ -45,19 +45,6 @@ describe('', () => { expect(screen.queryByText('Loading')).not.toBeInTheDocument(); }); - it('renders proper component based on the display mode', async () => { - const { user } = setUp({ filteredTags: ['foo', 'bar'], stats: {} }); - - expect(screen.getByText('TagsCards')).toBeInTheDocument(); - expect(screen.queryByText('TagsTable')).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /^Display mode/ })); - await user.click(screen.getByRole('menuitem', { name: /List/ })); - - expect(screen.queryByText('TagsCards')).not.toBeInTheDocument(); - expect(screen.getByText('TagsTable')).toBeInTheDocument(); - }); - it('triggers tags filtering when search field changes', async () => { const { user } = setUp({ filteredTags: [] }); diff --git a/test/tags/TagsModeDropdown.test.tsx b/test/tags/TagsModeDropdown.test.tsx deleted file mode 100644 index 9550a04b6..000000000 --- a/test/tags/TagsModeDropdown.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { screen } from '@testing-library/react'; -import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; -import { TagsMode } from '../../src/settings/reducers/settings'; -import { renderWithEvents } from '../__helpers__/setUpTest'; - -describe('', () => { - const onChange = jest.fn(); - const setUp = (mode: TagsMode) => renderWithEvents(); - - afterEach(jest.clearAllMocks); - - it.each([ - ['cards' as TagsMode], - ['list' as TagsMode], - ])('renders expected initial value', (mode) => { - setUp(mode); - expect(screen.getByRole('button')).toHaveTextContent(`Display mode: ${mode}`); - }); - - it('changes active element on click', async () => { - const { user } = setUp('list'); - const clickItem = async (index: 0 | 1) => { - await user.click(screen.getByRole('button')); - await user.click(screen.getAllByRole('menuitem')[index]); - }; - - expect(onChange).not.toHaveBeenCalled(); - await clickItem(0); - expect(onChange).toHaveBeenCalledWith('cards'); - - await clickItem(1); - expect(onChange).toHaveBeenCalledWith('list'); - }); -}); From 170f45d46ba68c3088e8623d237412d41710d200 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Dec 2022 19:59:55 +0100 Subject: [PATCH 03/56] Removed references to tagsMode setting --- src/settings/helpers/index.ts | 7 ------- src/settings/reducers/settings.ts | 3 --- test/settings/helpers/index.test.ts | 6 ------ 3 files changed, 16 deletions(-) diff --git a/src/settings/helpers/index.ts b/src/settings/helpers/index.ts index 68e922b67..10af7ccd8 100644 --- a/src/settings/helpers/index.ts +++ b/src/settings/helpers/index.ts @@ -11,12 +11,5 @@ export const migrateDeprecatedSettings = (state: Partial): Partial< state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days'); } - // The "tags display mode" option has been moved from "ui" to "tags" - state.settings.tags = { - ...state.settings.tags, - defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode, - }; - state.settings.ui && delete (state.settings.ui as any).tagsMode; - return state; }; diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index b707160ce..e64f33385 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -28,8 +28,6 @@ export interface ShortUrlCreationSettings { forwardQuery?: boolean; } -export type TagsMode = 'cards' | 'list'; - export interface UiSettings { theme: Theme; } @@ -40,7 +38,6 @@ export interface VisitsSettings { export interface TagsSettings { defaultOrdering?: TagsOrder; - defaultMode?: TagsMode; } export interface ShortUrlsListSettings { diff --git a/test/settings/helpers/index.test.ts b/test/settings/helpers/index.test.ts index ba3131255..0808ad358 100644 --- a/test/settings/helpers/index.test.ts +++ b/test/settings/helpers/index.test.ts @@ -14,9 +14,6 @@ describe('settings-helpers', () => { visits: { defaultInterval: 'last180days' as any, }, - ui: { - tagsMode: 'list', - } as any, }, }); @@ -25,9 +22,6 @@ describe('settings-helpers', () => { visits: { defaultInterval: 'last180Days', }, - tags: { - defaultMode: 'list', - }, }), })); }); From 4ef1e491bcdf9d12657031699069679ea2731443 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Dec 2022 20:01:31 +0100 Subject: [PATCH 04/56] Updated changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f0c3f30..dfab074ae 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). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* [#736](https://github.com/shlinkio/shlink-web-client/issues/736) Removed support for cards mode in tags. Only tags table is supported now. + +### Fixed +* *Nothing* + + ## [3.8.2] - 2022-12-17 ### Added * *Nothing* From 90837546abfa190356b915980172a73aba9b86bc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Dec 2022 10:12:34 +0100 Subject: [PATCH 05/56] Exported some specific component types and improved spacing in short URLs list --- src/servers/Overview.tsx | 4 ++-- src/short-urls/Paginator.tsx | 4 ++-- src/short-urls/ShortUrlsFilteringBar.tsx | 4 +++- src/short-urls/ShortUrlsList.tsx | 12 ++++++------ src/short-urls/ShortUrlsTable.scss | 4 ++++ src/short-urls/ShortUrlsTable.tsx | 12 +++++++----- src/short-urls/helpers/ShortUrlsRow.tsx | 4 +++- test/short-urls/Paginator.test.tsx | 6 ++++-- test/short-urls/ShortUrlsList.test.tsx | 5 ++--- 9 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 8903709da..d751a5347 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList'; import { prettify } from '../utils/helpers/numbers'; import { TagsList } from '../tags/reducers/tagsList'; -import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable'; +import { ShortUrlsTableType } from '../short-urls/ShortUrlsTable'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; @@ -25,7 +25,7 @@ interface OverviewConnectProps { } export const Overview = ( - ShortUrlsTable: FC, + ShortUrlsTable: ShortUrlsTableType, CreateShortUrl: FC, ) => boundToMercureHub(({ shortUrlsList, diff --git a/src/short-urls/Paginator.tsx b/src/short-urls/Paginator.tsx index 45c2fd10a..5e7d488e6 100644 --- a/src/short-urls/Paginator.tsx +++ b/src/short-urls/Paginator.tsx @@ -21,7 +21,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi `/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`; if (pagesCount <= 1) { - return null; + return
; // Return some space } const renderPages = () => @@ -38,7 +38,7 @@ export const Paginator = ({ paginator, serverId, currentQueryString = '' }: Pagi )); return ( - + diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 1a5ec29d4..b49280f7f 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -18,7 +18,7 @@ import { ExportShortUrlsBtnProps } from './helpers/ExportShortUrlsBtn'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import './ShortUrlsFilteringBar.scss'; -export interface ShortUrlsFilteringProps { +interface ShortUrlsFilteringProps { selectedServer: SelectedServer; order: ShortUrlsOrder; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; @@ -90,3 +90,5 @@ export const ShortUrlsFilteringBar = (
); }; + +export type ShortUrlsFilteringBarType = ReturnType; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 6b3f7a82a..c41a4f7d8 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,5 +1,5 @@ import { pipe } from 'ramda'; -import { FC, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card } from 'reactstrap'; import { useLocation, useParams } from 'react-router-dom'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; @@ -10,11 +10,11 @@ import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import { ShortUrlsTableProps } from './ShortUrlsTable'; +import { ShortUrlsTableType } from './ShortUrlsTable'; import { Paginator } from './Paginator'; import { useShortUrlsQuery } from './helpers/hooks'; import { ShortUrlsOrderableFields } from './data'; -import { ShortUrlsFilteringProps } from './ShortUrlsFilteringBar'; +import { ShortUrlsFilteringBarType } from './ShortUrlsFilteringBar'; interface ShortUrlsListProps { selectedServer: SelectedServer; @@ -24,8 +24,8 @@ interface ShortUrlsListProps { } export const ShortUrlsList = ( - ShortUrlsTable: FC, - ShortUrlsFilteringBar: FC, + ShortUrlsTable: ShortUrlsTableType, + ShortUrlsFilteringBar: ShortUrlsFilteringBarType, ) => boundToMercureHub(({ listShortUrls, shortUrlsList, selectedServer, settings }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); const { page } = useParams(); @@ -70,7 +70,7 @@ export const ShortUrlsList = ( handleOrderBy={handleOrderBy} className="mb-3" /> - + () => void; renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode; shortUrlsList: ShortUrlsListState; @@ -16,7 +16,7 @@ export interface ShortUrlsTableProps { className?: string; } -export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ +export const ShortUrlsTable = (ShortUrlsRow: ShortUrlsRowType) => ({ orderByColumn, renderOrderIcon, shortUrlsList, @@ -27,7 +27,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ const { error, loading, shortUrls } = shortUrlsList; const actionableFieldClasses = classNames({ 'short-urls-table__header-cell--with-action': !!orderByColumn }); const orderableColumnsClasses = classNames('short-urls-table__header-cell', actionableFieldClasses); - const tableClasses = classNames('table table-hover responsive-table', className); + const tableClasses = classNames('table table-hover responsive-table short-urls-table', className); const renderShortUrls = () => { if (error) { @@ -90,3 +90,5 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ ); }; + +export type ShortUrlsTableType = ReturnType; diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index 1b7fafb13..159c2b247 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -12,7 +12,7 @@ import { ShortUrlVisitsCount } from './ShortUrlVisitsCount'; import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; import './ShortUrlsRow.scss'; -export interface ShortUrlsRowProps { +interface ShortUrlsRowProps { onTagClick?: (tag: string) => void; selectedServer: SelectedServer; shortUrl: ShortUrl; @@ -89,3 +89,5 @@ export const ShortUrlsRow = ( ); }; + +export type ShortUrlsRowType = ReturnType; diff --git a/test/short-urls/Paginator.test.tsx b/test/short-urls/Paginator.test.tsx index a7a12ab5d..364c81966 100644 --- a/test/short-urls/Paginator.test.tsx +++ b/test/short-urls/Paginator.test.tsx @@ -18,9 +18,11 @@ describe('', () => { [buildPaginator()], [buildPaginator(0)], [buildPaginator(1)], - ])('renders nothing if the number of pages is below 2', (paginator) => { + ])('renders an empty gap if the number of pages is below 2', (paginator) => { const { container } = setUp(paginator); - expect(container.firstChild).toBeNull(); + + expect(container.firstChild).toBeEmpty(); + expect(container.firstChild).toHaveClass('pb-3'); }); it.each([ diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 06b062ed0..031dc98ef 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -1,5 +1,4 @@ import { screen } from '@testing-library/react'; -import { FC } from 'react'; import { Mock } from 'ts-mockery'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList'; @@ -8,7 +7,7 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import { ReachableServer } from '../../src/servers/data'; import { Settings } from '../../src/settings/reducers/settings'; -import { ShortUrlsTableProps } from '../../src/short-urls/ShortUrlsTable'; +import { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable'; import { renderWithEvents } from '../__helpers__/setUpTest'; jest.mock('react-router-dom', () => ({ @@ -18,7 +17,7 @@ jest.mock('react-router-dom', () => ({ })); describe('', () => { - const ShortUrlsTable: FC = ({ onTagClick }) => onTagClick?.('foo')}>ShortUrlsTable; + const ShortUrlsTable: ShortUrlsTableType = ({ onTagClick }) => onTagClick?.('foo')}>ShortUrlsTable; const ShortUrlsFilteringBar = () => ShortUrlsFilteringBar; const listShortUrlsMock = jest.fn(); const navigate = jest.fn(); From 187fee46f4bd2c93fcd289305a30b5ee5dc4baae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Dec 2022 13:17:49 +0100 Subject: [PATCH 06/56] Added extra info and new label to highlight disabled short URLs --- src/short-urls/helpers/DisabledLabel.tsx | 21 ++++++++++ .../helpers/ShortUrlVisitsCount.scss | 4 ++ .../helpers/ShortUrlVisitsCount.tsx | 38 +++++++++++++------ src/short-urls/helpers/ShortUrlsRow.scss | 4 -- src/short-urls/helpers/ShortUrlsRow.tsx | 38 ++++++++----------- src/short-urls/helpers/ShortUrlsRowMenu.tsx | 4 +- src/short-urls/helpers/Tags.tsx | 30 +++++++++++++++ src/short-urls/helpers/index.ts | 10 +++++ src/utils/helpers/date.ts | 2 + src/utils/table/ResponsiveTable.scss | 5 ++- 10 files changed, 117 insertions(+), 39 deletions(-) create mode 100644 src/short-urls/helpers/DisabledLabel.tsx create mode 100644 src/short-urls/helpers/Tags.tsx diff --git a/src/short-urls/helpers/DisabledLabel.tsx b/src/short-urls/helpers/DisabledLabel.tsx new file mode 100644 index 000000000..90f425976 --- /dev/null +++ b/src/short-urls/helpers/DisabledLabel.tsx @@ -0,0 +1,21 @@ +import { FC, useRef } from 'react'; +import { UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLinkSlash } from '@fortawesome/free-solid-svg-icons'; +import { mutableRefToElementRef } from '../../utils/helpers/components'; + +export const DisabledLabel: FC = () => { + const tooltipRef = useRef(); + + return ( + <> + + + Disabled + + tooltipRef.current) as any} placement="left"> + This short URL cannot be currently visited because of some of its limits. + + + ); +}; diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.scss b/src/short-urls/helpers/ShortUrlVisitsCount.scss index 2910381a1..7ae9b852b 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.scss +++ b/src/short-urls/helpers/ShortUrlVisitsCount.scss @@ -10,3 +10,7 @@ .short-url-visits-count__amount--big { transform: scale(1.5); } + +.short-url-visits-count__tooltip-list-item:not(:last-child) { + margin-bottom: .5rem; +} diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.tsx b/src/short-urls/helpers/ShortUrlVisitsCount.tsx index 3b76bda4d..dfa0acf30 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.tsx +++ b/src/short-urls/helpers/ShortUrlVisitsCount.tsx @@ -7,8 +7,9 @@ import { prettify } from '../../utils/helpers/numbers'; import { ShortUrl } from '../data'; import { SelectedServer } from '../../servers/data'; import { ShortUrlDetailLink } from './ShortUrlDetailLink'; -import './ShortUrlVisitsCount.scss'; import { mutableRefToElementRef } from '../../utils/helpers/components'; +import { formatHumanFriendly, parseISO } from '../../utils/helpers/date'; +import './ShortUrlVisitsCount.scss'; interface ShortUrlVisitsCountProps { shortUrl?: ShortUrl | null; @@ -20,7 +21,8 @@ interface ShortUrlVisitsCountProps { export const ShortUrlVisitsCount = ( { visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps, ) => { - const maxVisits = shortUrl?.meta?.maxVisits; + const { maxVisits, validSince, validUntil } = shortUrl?.meta ?? {}; + const hasLimit = !!maxVisits || !!validSince || !!validUntil; const visitsLink = ( ); - if (!maxVisits) { + if (!hasLimit) { return visitsLink; } - const prettifiedMaxVisits = prettify(maxVisits); const tooltipRef = useRef(); return ( <> {visitsLink} - - {' '}/ {prettifiedMaxVisits}{' '} - + + {maxVisits && <> / {prettify(maxVisits)}} + tooltipRef.current) as any} placement="bottom"> - This short URL will not accept more than {prettifiedMaxVisits} visits. +
    + {maxVisits && ( +
  • + This short URL will not accept more than {prettify(maxVisits)} visit{maxVisits === 1 ? '' : 's'}. +
  • + )} + {validSince && ( +
  • + This short URL will not accept visits + before {formatHumanFriendly(parseISO(validSince))}. +
  • + )} + {validUntil && ( +
  • + This short URL will not accept visits + after {formatHumanFriendly(parseISO(validUntil))}. +
  • + )} +
); diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index 4666be1a9..64af7818e 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -10,10 +10,6 @@ word-break: break-all; } -.short-urls-row__cell--relative { - position: relative; -} - .short-urls-row__cell--indivisible { @media (min-width: $lgMin) { white-space: nowrap; diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index 159c2b247..10f3e1a44 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -1,15 +1,16 @@ -import { FC, useEffect, useRef } from 'react'; -import { isEmpty } from 'ramda'; +import { useEffect, useRef } from 'react'; import { ExternalLink } from 'react-external-link'; import { ColorGenerator } from '../../utils/services/ColorGenerator'; import { TimeoutToggle } from '../../utils/helpers/hooks'; -import { Tag } from '../../tags/helpers/Tag'; import { SelectedServer } from '../../servers/data'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { ShortUrl } from '../data'; import { Time } from '../../utils/dates/Time'; import { ShortUrlVisitsCount } from './ShortUrlVisitsCount'; -import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; +import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu'; +import { Tags } from './Tags'; +import { shortUrlIsDisabled } from './index'; +import { DisabledLabel } from './DisabledLabel'; import './ShortUrlsRow.scss'; interface ShortUrlsRowProps { @@ -19,28 +20,14 @@ interface ShortUrlsRowProps { } export const ShortUrlsRow = ( - ShortUrlsRowMenu: FC, + ShortUrlsRowMenu: ShortUrlsRowMenuType, colorGenerator: ColorGenerator, useTimeoutToggle: TimeoutToggle, ) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => { const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); const [active, setActive] = useTimeoutToggle(false, 500); const isFirstRun = useRef(true); - - const renderTags = (tags: string[]) => { - if (isEmpty(tags)) { - return No tags; - } - - return tags.map((tag) => ( - onTagClick?.(tag)} - /> - )); - }; + const isDisabled = shortUrlIsDisabled(shortUrl); useEffect(() => { !isFirstRun.current && setActive(); @@ -53,7 +40,7 @@ export const ShortUrlsRow = (
diff --git a/src/visits/helpers/hooks.ts b/src/visits/helpers/hooks.ts index 2c78b65f4..54ccff47f 100644 --- a/src/visits/helpers/hooks.ts +++ b/src/visits/helpers/hooks.ts @@ -1,7 +1,7 @@ import { DeepPartial } from '@reduxjs/toolkit'; import { useLocation, useNavigate } from 'react-router-dom'; import { useMemo } from 'react'; -import { isEmpty, mergeDeepRight, pipe } from 'ramda'; +import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda'; import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals'; import { OrphanVisitType, VisitsFilter } from '../types'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; @@ -11,7 +11,7 @@ interface VisitsQuery { startDate?: string; endDate?: string; orphanVisitsType?: OrphanVisitType; - excludeBots?: 'true'; + excludeBots?: 'true' | 'false'; domain?: string; } @@ -38,7 +38,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => { domain, filtering: { dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined, - visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' }, + visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined }, }, }), ), @@ -49,7 +49,7 @@ export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => { const query: VisitsQuery = { startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '', endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '', - excludeBots: visitsFilter.excludeBots ? 'true' : undefined, + excludeBots: visitsFilter.excludeBots ? 'true' : 'false', orphanVisitsType: visitsFilter.orphanVisitsType, domain: theDomain, }; From 1d6f4bf5db9951638e82376df1259436fb4ef1f9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Dec 2022 20:00:59 +0100 Subject: [PATCH 20/56] =?UTF-8?q?Take=20into=20consideration=20excl=C3=B1u?= =?UTF-8?q?deBots=20from=20query=20on=20short=20URLs=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/short-urls/data/index.ts | 4 ++ .../helpers/ShortUrlsFilterDropdown.tsx | 38 ++++++++++++++ src/short-urls/helpers/ShortUrlsRow.tsx | 5 +- src/short-urls/helpers/hooks.ts | 15 ++++-- src/utils/utils.ts | 4 ++ src/visits/helpers/hooks.ts | 3 +- test/short-urls/helpers/ShortUrlsRow.test.tsx | 52 +++++++++++++------ 7 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 src/short-urls/helpers/ShortUrlsFilterDropdown.tsx diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 8c22bdec6..343f385a7 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -80,3 +80,7 @@ export interface ExportableShortUrl { tags: string; visits: number; } + +export interface ShortUrlsFilter { + excludeBots?: boolean; +} diff --git a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx b/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx new file mode 100644 index 000000000..47b258146 --- /dev/null +++ b/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx @@ -0,0 +1,38 @@ +import { DropdownItem } from 'reactstrap'; +import { DropdownBtn } from '../../utils/DropdownBtn'; +import { hasValue } from '../../utils/utils'; +import { ShortUrlsFilter } from '../data'; + +interface ShortUrlsFilterDropdownProps { + onChange: (filters: ShortUrlsFilter) => void; + selected?: ShortUrlsFilter; + className?: string; + botsSupported: boolean; +} + +export const ShortUrlsFilterDropdown = ( + { onChange, selected = {}, className, botsSupported }: ShortUrlsFilterDropdownProps, +) => { + if (!botsSupported) { + return null; + } + + const { excludeBots = false } = selected; + const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); + + return ( + + {botsSupported && ( + <> + Bots: + Exclude bots visits + + )} + + + onChange({ excludeBots: false })}> + Clear filters + + + ); +}; diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index 549266c46..1104ba837 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -11,6 +11,7 @@ import { ShortUrlVisitsCount } from './ShortUrlVisitsCount'; import { ShortUrlsRowMenuType } from './ShortUrlsRowMenu'; import { Tags } from './Tags'; import { ShortUrlStatus } from './ShortUrlStatus'; +import { useShortUrlsQuery } from './hooks'; import './ShortUrlsRow.scss'; interface ShortUrlsRowProps { @@ -33,7 +34,9 @@ export const ShortUrlsRow = ( const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); const [active, setActive] = useTimeoutToggle(false, 500); const isFirstRun = useRef(true); + const [{ excludeBots }] = useShortUrlsQuery(); const { visits } = settings; + const doExcludeBots = excludeBots ?? visits?.excludeBots; useEffect(() => { !isFirstRun.current && setActive(); @@ -73,7 +76,7 @@ export const ShortUrlsRow = ( ) => void; @@ -33,20 +36,26 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { const filtering = useMemo( pipe( () => parseQuery(search), - ({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { + ({ orderBy, tags, excludeBots, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { const parsedOrderBy = orderBy ? stringToOrder(orderBy) : undefined; const parsedTags = tags?.split(',') ?? []; - return { ...rest, orderBy: parsedOrderBy, tags: parsedTags }; + return { + ...rest, + orderBy: parsedOrderBy, + tags: parsedTags, + excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined, + }; }, ), [search], ); const toFirstPageWithExtra = (extra: Partial) => { - const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra }; + const { orderBy, tags, excludeBots, ...mergedFiltering } = { ...filtering, ...extra }; const query: ShortUrlsQuery = { ...mergedFiltering, orderBy: orderBy && orderToString(orderBy), tags: tags.length > 0 ? tags.join(',') : undefined, + excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots), }; const stringifiedQuery = stringifyQuery(query); const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8eda72569..504d450e8 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -26,3 +26,7 @@ export const nonEmptyValueOrNull = (value: T): T | null => (isEmpty(value) ? export const capitalize = (value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`; export const equals = (value: any) => (otherValue: any) => value === otherValue; + +export type BooleanString = 'true' | 'false'; + +export const parseBooleanToString = (value: boolean): BooleanString => (value ? 'true' : 'false'); diff --git a/src/visits/helpers/hooks.ts b/src/visits/helpers/hooks.ts index 54ccff47f..fd6470238 100644 --- a/src/visits/helpers/hooks.ts +++ b/src/visits/helpers/hooks.ts @@ -6,12 +6,13 @@ import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals'; import { OrphanVisitType, VisitsFilter } from '../types'; import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { formatIsoDate } from '../../utils/helpers/date'; +import { BooleanString } from '../../utils/utils'; interface VisitsQuery { startDate?: string; endDate?: string; orphanVisitsType?: OrphanVisitType; - excludeBots?: 'true' | 'false'; + excludeBots?: BooleanString; domain?: string; } diff --git a/test/short-urls/helpers/ShortUrlsRow.test.tsx b/test/short-urls/helpers/ShortUrlsRow.test.tsx index 9884b0e13..83b9b4f81 100644 --- a/test/short-urls/helpers/ShortUrlsRow.test.tsx +++ b/test/short-urls/helpers/ShortUrlsRow.test.tsx @@ -2,6 +2,7 @@ import { screen } from '@testing-library/react'; import { last } from 'ramda'; import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow'; import { TimeoutToggle } from '../../../src/utils/helpers/hooks'; import { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data'; @@ -19,6 +20,11 @@ interface SetUpOptions { settings?: Partial; } +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn().mockReturnValue({}), +})); + describe('', () => { const timeoutToggle = jest.fn(() => true); const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle; @@ -43,18 +49,24 @@ describe('', () => { }, }; const ShortUrlsRow = createShortUrlsRow(() => ShortUrlsRowMenu, colorGeneratorMock, useTimeoutToggle); - const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}) => renderWithEvents( - - - null} - settings={Mock.of(settings)} - /> - -
, - ); + + const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => { + (useLocation as any).mockReturnValue({ search }); + return renderWithEvents( + + + + null} + settings={Mock.of(settings)} + /> + +
+
, + ); + }; it.each([ [null, 7], @@ -105,11 +117,17 @@ describe('', () => { }); it.each([ - [{}, shortUrl.visitsSummary?.total], - [Mock.of({ visits: { excludeBots: false } }), shortUrl.visitsSummary?.total], - [Mock.of({ visits: { excludeBots: true } }), shortUrl.visitsSummary?.nonBots], - ])('renders visits count in fifth row', (settings, expectedAmount) => { - setUp({ settings }); + [{}, '', shortUrl.visitsSummary?.total], + [Mock.of({ visits: { excludeBots: false } }), '', shortUrl.visitsSummary?.total], + [Mock.of({ visits: { excludeBots: true } }), '', shortUrl.visitsSummary?.nonBots], + [Mock.of({ visits: { excludeBots: false } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots], + [Mock.of({ visits: { excludeBots: true } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots], + [{}, 'excludeBots=true', shortUrl.visitsSummary?.nonBots], + [Mock.of({ visits: { excludeBots: true } }), 'excludeBots=false', shortUrl.visitsSummary?.total], + [Mock.of({ visits: { excludeBots: false } }), 'excludeBots=false', shortUrl.visitsSummary?.total], + [{}, 'excludeBots=false', shortUrl.visitsSummary?.total], + ])('renders visits count in fifth row', (settings, search, expectedAmount) => { + setUp({ settings }, search); expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${expectedAmount}`); }); From b00f6fadf806dc309a1bd7a47b5962c5063c73c8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Dec 2022 20:03:27 +0100 Subject: [PATCH 21/56] Added test for parseBooleanToString --- test/utils/utils.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index a092a1e50..d08a5feed 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { capitalize, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; +import { capitalize, nonEmptyValueOrNull, parseBooleanToString, rangeOf } from '../../src/utils/utils'; describe('utils', () => { describe('rangeOf', () => { @@ -49,4 +49,13 @@ describe('utils', () => { expect(capitalize(value)).toEqual(expectedResult); }); }); + + describe('parseBooleanToString', () => { + it.each([ + [true, 'true'], + [false, 'false'], + ])('parses value as expected', (value, expectedResult) => { + expect(parseBooleanToString(value)).toEqual(expectedResult); + }); + }); }); From e790360de9624777da1ebf0d79b12a3e2b032130 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Dec 2022 20:15:52 +0100 Subject: [PATCH 22/56] Added filtering dropdown to short URLs filtering bar --- src/short-urls/ShortUrlsFilteringBar.tsx | 30 ++++++++++++++----- src/short-urls/ShortUrlsList.tsx | 6 ++-- src/short-urls/helpers/ExportShortUrlsBtn.tsx | 2 +- .../short-urls/ShortUrlsFilteringBar.test.tsx | 20 +++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index b49280f7f..fa7cb9012 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -8,7 +8,7 @@ import { SearchField } from '../utils/SearchField'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { formatIsoDate } from '../utils/helpers/date'; import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals'; -import { supportsAllTagsFiltering } from '../utils/helpers/features'; +import { supportsAllTagsFiltering, supportsBotVisits } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { OrderDir } from '../utils/helpers/ordering'; import { OrderingDropdown } from '../utils/OrderingDropdown'; @@ -16,11 +16,14 @@ 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 { ShortUrlsFilterDropdown } from './helpers/ShortUrlsFilterDropdown'; +import { Settings } from '../settings/reducers/settings'; import './ShortUrlsFilteringBar.scss'; interface ShortUrlsFilteringProps { selectedServer: SelectedServer; order: ShortUrlsOrder; + settings: Settings; handleOrderBy: (orderField?: ShortUrlsOrderableFields, orderDir?: OrderDir) => void; className?: string; shortUrlsAmount?: number; @@ -29,8 +32,8 @@ interface ShortUrlsFilteringProps { export const ShortUrlsFilteringBar = ( ExportShortUrlsBtn: FC, TagsSelector: FC, -): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy }) => { - const [{ search, tags, startDate, endDate, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery(); +): FC => ({ selectedServer, className, shortUrlsAmount, order, handleOrderBy, settings }) => { + const [{ search, tags, startDate, endDate, excludeBots, tagsMode = 'any' }, toFirstPage] = useShortUrlsQuery(); const setDates = pipe( ({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({ startDate: formatIsoDate(theStartDate) ?? undefined, @@ -44,6 +47,7 @@ export const ShortUrlsFilteringBar = ( ); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); + const botsSupported = supportsBotVisits(selectedServer); const toggleTagsMode = pipe( () => (tagsMode === 'any' ? 'all' : 'any'), (mode) => toFirstPage({ tagsMode: mode }), @@ -69,11 +73,21 @@ export const ShortUrlsFilteringBar = (
- +
+
+ +
+ +
diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 60bfa759c..a124368c8 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -31,12 +31,13 @@ export const ShortUrlsList = ( const serverId = getServerId(selectedServer); const { page } = useParams(); const location = useLocation(); - const [{ tags, search, startDate, endDate, orderBy, tagsMode }, toFirstPage] = useShortUrlsQuery(); + const [{ tags, search, startDate, endDate, orderBy, tagsMode, excludeBots }, toFirstPage] = useShortUrlsQuery(); const [actualOrderBy, setActualOrderBy] = useState( // This separated state handling is needed to be able to fall back to settings value, but only once when loaded orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, ); const { pagination } = shortUrlsList?.shortUrls ?? {}; + const doExcludeBots = excludeBots ?? settings.visits?.excludeBots; const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { toFirstPage({ orderBy: { field, dir } }); setActualOrderBy({ field, dir }); @@ -50,7 +51,7 @@ export const ShortUrlsList = ( (updatedTags) => toFirstPage({ tags: updatedTags }), ); const parseOrderByForShlink = ({ field, dir }: ShortUrlsOrder): ShlinkShortUrlsOrder => { - if (supportsExcludeBotsOnShortUrls(selectedServer) && settings.visits?.excludeBots && field === 'visits') { + if (supportsExcludeBotsOnShortUrls(selectedServer) && doExcludeBots && field === 'visits') { return { field: 'nonBotVisits', dir }; } @@ -76,6 +77,7 @@ export const ShortUrlsList = ( shortUrlsAmount={shortUrlsList.shortUrls?.pagination.totalItems} order={actualOrderBy} handleOrderBy={handleOrderBy} + settings={settings} className="mb-3" /> diff --git a/src/short-urls/helpers/ExportShortUrlsBtn.tsx b/src/short-urls/helpers/ExportShortUrlsBtn.tsx index 5a5f12334..35403d1db 100644 --- a/src/short-urls/helpers/ExportShortUrlsBtn.tsx +++ b/src/short-urls/helpers/ExportShortUrlsBtn.tsx @@ -52,7 +52,7 @@ export const ExportShortUrlsBtn = ( longUrl: shortUrl.longUrl, title: shortUrl.title ?? '', tags: shortUrl.tags.join(','), - visits: shortUrl.visitsCount, + visits: shortUrl?.visitsSummary?.total ?? shortUrl.visitsCount, }))); stopLoading(); }; diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index d8f30942a..ad366d98f 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -4,6 +4,7 @@ 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 { Settings } from '../../src/settings/reducers/settings'; import { DateRange } from '../../src/utils/helpers/dateIntervals'; import { formatDate } from '../../src/utils/helpers/date'; import { renderWithEvents } from '../__helpers__/setUpTest'; @@ -30,6 +31,7 @@ describe('', () => { selectedServer={selectedServer ?? Mock.all()} order={{}} handleOrderBy={handleOrderBy} + settings={Mock.of({ visits: {} })} /> , ); @@ -114,6 +116,24 @@ describe('', () => { expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode)); }); + it.each([ + ['', 'excludeBots=true'], + ['excludeBots=false', 'excludeBots=true'], + ['excludeBots=true', 'excludeBots=false'], + ])('allows to toggle excluding bots through filtering dropdown', async (search, expectedQuery) => { + const { user } = setUp( + search, + Mock.of({ version: '3.4.0' }), + ); + const toggleBots = async (name = 'Exclude bots visits') => { + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.click(await screen.findByRole('menuitem', { name })); + }; + + await toggleBots(); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery)); + }); + it('handles order through dropdown', async () => { const { user } = setUp(); const clickMenuItem = async (name: string | RegExp) => { From ddb2c1e6410b1acf51e06ea199af6cae0f11f988 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Dec 2022 20:24:55 +0100 Subject: [PATCH 23/56] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 825f3a2e5..92ef80713 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 * [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc. +* [#764](https://github.com/shlinkio/shlink-web-client/issues/764) Added support to exclude visits from visits on short URLs list when consuming Shlink 3.4.0. + + This feature also comes with a new setting to disable visits from bots by default, both on short URLs lists and visits sections. ### Changed * *Nothing* From 815e06809a3a6da90e145e8bef4485dd10e20e2f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Dec 2022 20:42:47 +0100 Subject: [PATCH 24/56] Removed references to feature checks for version 2.8 --- src/common/AsideMenu.tsx | 12 +++------ src/common/MenuLayout.tsx | 5 ++-- src/short-urls/helpers/QrCodeModal.tsx | 26 ++++++++------------ src/utils/helpers/features.ts | 2 -- src/utils/helpers/qrCodes.ts | 14 ++--------- test/common/AsideMenu.test.tsx | 16 +++++------- test/common/MenuLayout.test.tsx | 1 - test/short-urls/helpers/QrCodeModal.test.tsx | 23 +++++++---------- 8 files changed, 33 insertions(+), 66 deletions(-) diff --git a/src/common/AsideMenu.tsx b/src/common/AsideMenu.tsx index f7c819654..2e36c27a9 100644 --- a/src/common/AsideMenu.tsx +++ b/src/common/AsideMenu.tsx @@ -12,7 +12,6 @@ import { NavLink, NavLinkProps, useLocation } from 'react-router-dom'; import classNames from 'classnames'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; import { isServerWithId, SelectedServer } from '../servers/data'; -import { supportsDomainRedirects } from '../utils/helpers/features'; import './AsideMenu.scss'; export interface AsideMenuProps { @@ -40,7 +39,6 @@ export const AsideMenu = (DeleteServerButton: FC) => ( const hasId = isServerWithId(selectedServer); const serverId = hasId ? selectedServer.id : ''; const { pathname } = useLocation(); - const addManageDomainsLink = supportsDomainRedirects(selectedServer); const asideClass = classNames('aside-menu', { 'aside-menu--hidden': !showOnMobile, }); @@ -68,12 +66,10 @@ export const AsideMenu = (DeleteServerButton: FC) => ( Manage tags - {addManageDomainsLink && ( - - - Manage domains - - )} + + + Manage domains + Edit this server diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 03de8768e..0fc05448d 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { useSwipeable, useToggle } from '../utils/helpers/hooks'; -import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features'; +import { supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features'; import { isReachableServer } from '../servers/data'; import { NotFound } from './NotFound'; import { AsideMenuProps } from './AsideMenu'; @@ -47,7 +47,6 @@ export const MenuLayout = ( } const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer); - const addManageDomainsRoute = supportsDomainRedirects(selectedServer); const addDomainVisitsRoute = supportsDomainVisits(selectedServer); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const swipeableProps = useSwipeable(showSidebar, hideSidebar); @@ -73,7 +72,7 @@ export const MenuLayout = ( } /> {addNonOrphanVisitsRoute && } />} } /> - {addManageDomainsRoute && } />} + } /> List short URLs} diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index ac9718067..f4bda9ebd 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -6,8 +6,8 @@ import { ExternalLink } from 'react-external-link'; import { ShortUrlModalProps } from '../data'; import { SelectedServer } from '../../servers/data'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; -import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; -import { supportsNonRestCors, supportsQrErrorCorrection } from '../../utils/helpers/features'; +import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; +import { supportsNonRestCors } from '../../utils/helpers/features'; import { ImageDownloader } from '../../common/services/ImageDownloader'; import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; @@ -24,14 +24,10 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => ( const [margin, setMargin] = useState(0); const [format, setFormat] = useState('png'); const [errorCorrection, setErrorCorrection] = useState('L'); - const capabilities: QrCodeCapabilities = useMemo(() => ({ - errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer), - }), [selectedServer]); const displayDownloadBtn = supportsNonRestCors(selectedServer); - const willRenderThreeControls = !capabilities.errorCorrectionIsSupported; const qrCodeUrl = useMemo( - () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), - [shortUrl, size, format, margin, errorCorrection, capabilities], + () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }), + [shortUrl, size, format, margin, errorCorrection], ); const totalSize = useMemo(() => size + margin, [size, margin]); const modalSize = useMemo(() => { @@ -49,7 +45,7 @@ export const QrCodeModal = (imageDownloader: ImageDownloader) => ( - + ( onChange={(e) => setSize(Number(e.target.value))} /> - + ( onChange={(e) => setMargin(Number(e.target.value))} /> - + - {capabilities.errorCorrectionIsSupported && ( - - - - )} + + +
diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 90f81a51d..bfc718661 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -6,8 +6,6 @@ const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: export const supportsBotVisits = serverMatchesMinVersion('2.7.0'); export const supportsCrawlableVisits = supportsBotVisits; -export const supportsQrErrorCorrection = serverMatchesMinVersion('2.8.0'); -export const supportsDomainRedirects = supportsQrErrorCorrection; export const supportsForwardQuery = serverMatchesMinVersion('2.9.0'); export const supportsNonRestCors = supportsForwardQuery; export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0'); diff --git a/src/utils/helpers/qrCodes.ts b/src/utils/helpers/qrCodes.ts index 85feb8882..2e66d6763 100644 --- a/src/utils/helpers/qrCodes.ts +++ b/src/utils/helpers/qrCodes.ts @@ -1,10 +1,6 @@ import { isEmpty } from 'ramda'; import { stringifyQuery } from './query'; -export interface QrCodeCapabilities { - errorCorrectionIsSupported: boolean; -} - export type QrCodeFormat = 'svg' | 'png'; export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H'; @@ -16,17 +12,11 @@ export interface QrCodeOptions { errorCorrection: QrErrorCorrection; } -export const buildQrCodeUrl = ( - shortUrl: string, - { size, format, margin, errorCorrection }: QrCodeOptions, - { errorCorrectionIsSupported }: QrCodeCapabilities, -): string => { +export const buildQrCodeUrl = (shortUrl: string, { margin, ...options }: QrCodeOptions): string => { const baseUrl = `${shortUrl}/qr-code`; const query = stringifyQuery({ - size, - format, + ...options, margin: margin > 0 ? margin : undefined, - errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined, }); return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`; diff --git a/test/common/AsideMenu.test.tsx b/test/common/AsideMenu.test.tsx index df49244ee..6874053dc 100644 --- a/test/common/AsideMenu.test.tsx +++ b/test/common/AsideMenu.test.tsx @@ -3,26 +3,22 @@ import { Mock } from 'ts-mockery'; import { MemoryRouter } from 'react-router-dom'; import { AsideMenu as createAsideMenu } from '../../src/common/AsideMenu'; import { ReachableServer } from '../../src/servers/data'; -import { SemVer } from '../../src/utils/helpers/version'; describe('', () => { const AsideMenu = createAsideMenu(() => <>DeleteServerButton); - const setUp = (version: SemVer, id: string | false = 'abc123') => render( + const setUp = (id: string | false = 'abc123') => render( - ({ id: id || undefined, version })} /> + ({ id: id || undefined, version: '2.8.0' })} /> , ); - it.each([ - ['2.7.0' as SemVer, 5], - ['2.8.0' as SemVer, 6], - ])('contains links to different sections', (version, expectedAmountOfLinks) => { - setUp(version); + it('contains links to different sections', () => { + setUp(); const links = screen.getAllByRole('link'); expect.assertions(links.length + 1); - expect(links).toHaveLength(expectedAmountOfLinks); + expect(links).toHaveLength(6); links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123')); }); @@ -30,7 +26,7 @@ describe('', () => { ['abc', true], [false, false], ])('contains a button to delete server if appropriate', (id, shouldHaveBtn) => { - setUp('2.8.0', id as string | false); + setUp(id as string | false); if (shouldHaveBtn) { expect(screen.getByText('DeleteServerButton')).toBeInTheDocument(); diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx index 7fefe3fc5..e2ebc4682 100644 --- a/test/common/MenuLayout.test.tsx +++ b/test/common/MenuLayout.test.tsx @@ -77,7 +77,6 @@ describe('', () => { ['3.1.0' as SemVer, '/domain/domain.com/visits/foo', 'DomainVisits'], ['2.10.0' as SemVer, '/non-orphan-visits/foo', 'Oops! We could not find requested route.'], ['3.0.0' as SemVer, '/non-orphan-visits/foo', 'NonOrphanVisits'], - ['2.7.0' as SemVer, '/manage-domains', 'Oops! We could not find requested route.'], ['2.8.0' as SemVer, '/manage-domains', 'ManageDomains'], ])( 'renders expected component based on location and server version', diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx index bdf120e3c..b5bacdbe8 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/test/short-urls/helpers/QrCodeModal.test.tsx @@ -11,7 +11,7 @@ describe('', () => { const saveImage = jest.fn().mockReturnValue(Promise.resolve()); const QrCodeModal = createQrCodeModal(Mock.of({ saveImage })); const shortUrl = 'https://doma.in/abc123'; - const setUp = (version: SemVer = '2.6.0') => renderWithEvents( + const setUp = (version: SemVer = '2.8.0') => renderWithEvents( ({ shortUrl })} @@ -32,12 +32,10 @@ describe('', () => { }); it.each([ - ['2.5.0' as SemVer, 0, '/qr-code?size=300&format=png'], - ['2.6.0' as SemVer, 0, '/qr-code?size=300&format=png'], - ['2.6.0' as SemVer, 10, '/qr-code?size=300&format=png&margin=10'], - ['2.8.0' as SemVer, 0, '/qr-code?size=300&format=png&errorCorrection=L'], - ])('displays an image with the QR code of the URL', async (version, margin, expectedUrl) => { - const { container } = setUp(version); + [10, '/qr-code?size=300&format=png&errorCorrection=L&margin=10'], + [0, '/qr-code?size=300&format=png&errorCorrection=L'], + ])('displays an image with the QR code of the URL', async (margin, expectedUrl) => { + const { container } = setUp(); const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1); if (marginControl) { @@ -69,16 +67,13 @@ describe('', () => { modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`); }); - it.each([ - ['2.6.0' as SemVer, 1, 'col-md-4'], - ['2.8.0' as SemVer, 2, 'col-md-6'], - ])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => { - const { container } = setUp(version); + it('shows expected components based on server version', () => { + const { container } = setUp(); const dropdowns = screen.getAllByRole('button'); const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0); - expect(dropdowns).toHaveLength(expectedAmountOfDropdowns + 1); // Add one because of the close button - expect(firstCol).toHaveClass(expectedRangeClass); + expect(dropdowns).toHaveLength(2 + 1); // Add one because of the close button + expect(firstCol).toHaveClass('col-md-4'); }); it('saves the QR code image when clicking the Download button', async () => { From 60fc351344854eae5ef2244ba3e0e5a02b2505ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Dec 2022 21:06:59 +0100 Subject: [PATCH 25/56] Removed references to feature checks for version 2.7 --- src/short-urls/ShortUrlForm.tsx | 50 ++++++++----------- src/short-urls/ShortUrlsFilteringBar.tsx | 4 +- .../helpers/ShortUrlsFilterDropdown.tsx | 15 ++---- src/utils/helpers/features.ts | 2 - src/visits/DomainVisits.tsx | 2 - src/visits/NonOrphanVisits.tsx | 2 - src/visits/OrphanVisits.tsx | 2 - src/visits/ShortUrlVisits.tsx | 2 - src/visits/TagVisits.tsx | 2 - src/visits/VisitsStats.tsx | 7 --- src/visits/VisitsTable.tsx | 39 ++++++--------- src/visits/helpers/VisitsFilterDropdown.tsx | 18 ++----- src/visits/services/provideServices.ts | 10 ++-- src/visits/types/CommonVisitsProps.ts | 2 - test/short-urls/ShortUrlForm.test.tsx | 2 +- test/utils/helpers/qrCodes.test.ts | 23 +++------ test/visits/DomainVisits.test.tsx | 2 - test/visits/NonOrphanVisits.test.tsx | 2 - test/visits/OrphanVisits.test.tsx | 2 - test/visits/VisitsStats.test.tsx | 2 - test/visits/VisitsTable.test.tsx | 41 +++++---------- .../helpers/VisitsFilterDropdown.test.tsx | 8 +-- 22 files changed, 71 insertions(+), 168 deletions(-) diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 8c3f66356..f949de665 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -4,7 +4,7 @@ import { Button, FormGroup, Input, Row } from 'reactstrap'; import { cond, isEmpty, pipe, replace, trim, T } from 'ramda'; import { parseISO } from 'date-fns'; import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput'; -import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features'; +import { supportsForwardQuery } from '../utils/helpers/features'; import { SimpleCard } from '../utils/SimpleCard'; import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils'; import { Checkbox } from '../utils/Checkbox'; @@ -113,16 +113,14 @@ export const ShortUrlForm = ( ); - const showCrawlableControl = supportsCrawlableVisits(selectedServer); const showForwardQueryControl = supportsForwardQuery(selectedServer); - const showBehaviorCard = showCrawlableControl || showForwardQueryControl; return (
{isBasicMode && basicComponents} {!isBasicMode && ( <> - + {basicComponents} @@ -190,30 +188,26 @@ export const ShortUrlForm = ( )}
- {showBehaviorCard && ( -
- - {showCrawlableControl && ( - setShortUrlData({ ...shortUrlData, crawlable })} - > - Make it crawlable - - )} - {showForwardQueryControl && ( - setShortUrlData({ ...shortUrlData, forwardQuery })} - > - Forward query params on redirect - - )} - -
- )} +
+ + setShortUrlData({ ...shortUrlData, crawlable })} + > + Make it crawlable + + {showForwardQueryControl && ( + setShortUrlData({ ...shortUrlData, forwardQuery })} + > + Forward query params on redirect + + )} + +
)} diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index fa7cb9012..85c708913 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -8,7 +8,7 @@ import { SearchField } from '../utils/SearchField'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import { formatIsoDate } from '../utils/helpers/date'; import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals'; -import { supportsAllTagsFiltering, supportsBotVisits } from '../utils/helpers/features'; +import { supportsAllTagsFiltering } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; import { OrderDir } from '../utils/helpers/ordering'; import { OrderingDropdown } from '../utils/OrderingDropdown'; @@ -47,7 +47,6 @@ export const ShortUrlsFilteringBar = ( ); const changeTagSelection = (selectedTags: string[]) => toFirstPage({ tags: selectedTags }); const canChangeTagsMode = supportsAllTagsFiltering(selectedServer); - const botsSupported = supportsBotVisits(selectedServer); const toggleTagsMode = pipe( () => (tagsMode === 'any' ? 'all' : 'any'), (mode) => toFirstPage({ tagsMode: mode }), @@ -83,7 +82,6 @@ export const ShortUrlsFilteringBar = (
diff --git a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx b/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx index 47b258146..4b36d5022 100644 --- a/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx +++ b/src/short-urls/helpers/ShortUrlsFilterDropdown.tsx @@ -7,27 +7,18 @@ interface ShortUrlsFilterDropdownProps { onChange: (filters: ShortUrlsFilter) => void; selected?: ShortUrlsFilter; className?: string; - botsSupported: boolean; } export const ShortUrlsFilterDropdown = ( - { onChange, selected = {}, className, botsSupported }: ShortUrlsFilterDropdownProps, + { onChange, selected = {}, className }: ShortUrlsFilterDropdownProps, ) => { - if (!botsSupported) { - return null; - } - const { excludeBots = false } = selected; const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); return ( - {botsSupported && ( - <> - Bots: - Exclude bots visits - - )} + Bots: + Exclude bots visits onChange({ excludeBots: false })}> diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index bfc718661..bfb88215f 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -4,8 +4,6 @@ import { SemVerPattern, versionMatch } from './version'; const serverMatchesMinVersion = (minVersion: SemVerPattern) => (selectedServer: SelectedServer): boolean => isReachableServer(selectedServer) && versionMatch(selectedServer.version, { minVersion }); -export const supportsBotVisits = serverMatchesMinVersion('2.7.0'); -export const supportsCrawlableVisits = supportsBotVisits; export const supportsForwardQuery = serverMatchesMinVersion('2.9.0'); export const supportsNonRestCors = supportsForwardQuery; export const supportsDefaultDomainRedirectsEdition = serverMatchesMinVersion('2.10.0'); diff --git a/src/visits/DomainVisits.tsx b/src/visits/DomainVisits.tsx index 13232a694..e2f5d3bad 100644 --- a/src/visits/DomainVisits.tsx +++ b/src/visits/DomainVisits.tsx @@ -22,7 +22,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure domainVisits, cancelGetDomainVisits, settings, - selectedServer, }: DomainVisitsProps) => { const goBack = useGoBack(); const { domain = '' } = useParams(); @@ -38,7 +37,6 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure visitsInfo={domainVisits} settings={settings} exportCsv={exportCsv} - selectedServer={selectedServer} > diff --git a/src/visits/NonOrphanVisits.tsx b/src/visits/NonOrphanVisits.tsx index 8c9395589..3515afb8e 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/src/visits/NonOrphanVisits.tsx @@ -20,7 +20,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc nonOrphanVisits, cancelGetNonOrphanVisits, settings, - selectedServer, }: NonOrphanVisitsProps) => { const goBack = useGoBack(); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits); @@ -34,7 +33,6 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc visitsInfo={nonOrphanVisits} settings={settings} exportCsv={exportCsv} - selectedServer={selectedServer} > diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index b35ed6d35..3a7d711b0 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -21,7 +21,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure orphanVisits, cancelGetOrphanVisits, settings, - selectedServer, }: OrphanVisitsProps) => { const goBack = useGoBack(); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); @@ -36,7 +35,6 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure visitsInfo={orphanVisits} settings={settings} exportCsv={exportCsv} - selectedServer={selectedServer} isOrphanVisits > diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index 0d94f87a0..47b23d275 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -30,7 +30,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu getShortUrlDetail, cancelGetShortUrlVisits, settings, - selectedServer, }: ShortUrlVisitsProps) => { const { shortCode = '' } = useParams<{ shortCode: string }>(); const { search } = useLocation(); @@ -57,7 +56,6 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu visitsInfo={shortUrlVisits} settings={settings} exportCsv={exportCsv} - selectedServer={selectedServer} > diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index ff70f1bfb..3d2510c10 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -23,7 +23,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo tagVisits, cancelGetTagVisits, settings, - selectedServer, }: TagVisitsProps) => { const goBack = useGoBack(); const { tag = '' } = useParams(); @@ -38,7 +37,6 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo visitsInfo={tagVisits} settings={settings} exportCsv={exportCsv} - selectedServer={selectedServer} > diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index d823fa9d1..c381e3007 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -11,8 +11,6 @@ import { Message } from '../utils/Message'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { Settings } from '../settings/reducers/settings'; -import { SelectedServer } from '../servers/data'; -import { supportsBotVisits } from '../utils/helpers/features'; import { prettify } from '../utils/helpers/numbers'; import { NavPillItem, NavPills } from '../utils/NavPills'; import { ExportBtn } from '../utils/ExportBtn'; @@ -33,7 +31,6 @@ export type VisitsStatsProps = PropsWithChildren<{ getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; visitsInfo: VisitsInfo; settings: Settings; - selectedServer: SelectedServer; cancelGetVisits: () => void; exportCsv: (visits: NormalizedVisit[]) => void; isOrphanVisits?: boolean; @@ -63,7 +60,6 @@ export const VisitsStats: FC = ({ cancelGetVisits, settings, exportCsv, - selectedServer, isOrphanVisits = false, }) => { const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; @@ -82,7 +78,6 @@ export const VisitsStats: FC = ({ ); const [highlightedVisits, setHighlightedVisits] = useState([]); const [highlightedLabel, setHighlightedLabel] = useState(); - const botsSupported = supportsBotVisits(selectedServer); const isFirstLoad = useRef(true); const { search } = useLocation(); @@ -273,7 +268,6 @@ export const VisitsStats: FC = ({ selectedVisits={highlightedVisits} setSelectedVisits={setSelectedVisits} isOrphanVisits={isOrphanVisits} - selectedServer={selectedServer} />
)} @@ -306,7 +300,6 @@ export const VisitsStats: FC = ({ updateFiltering({ visitsFilter: newVisitsFilter })} /> diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 16181b49d..311ce1609 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -8,8 +8,6 @@ import { SimplePaginator } from '../common/SimplePaginator'; 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'; -import { SelectedServer } from '../servers/data'; import { Time } from '../utils/dates/Time'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { MediaMatcher } from '../utils/types'; @@ -22,7 +20,6 @@ export interface VisitsTableProps { setSelectedVisits: (visits: NormalizedVisit[]) => void; matchMedia?: MediaMatcher; isOrphanVisits?: boolean; - selectedServer: SelectedServer; } type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; @@ -49,7 +46,6 @@ export const VisitsTable = ({ visits, selectedVisits = [], setSelectedVisits, - selectedServer, matchMedia = window.matchMedia, isOrphanVisits = false, }: VisitsTableProps) => { @@ -64,8 +60,7 @@ export const VisitsTable = ({ const [page, setPage] = useState(1); const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; - const supportsBots = supportsBotVisits(selectedServer); - const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits); + const fullSizeColSpan = 8 + Number(isOrphanVisits); const orderByColumn = (field: OrderableFields) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); @@ -99,12 +94,10 @@ export const VisitsTable = ({ > 0 })} /> - {supportsBots && ( - - - {renderOrderIcon('potentialBot')} - - )} + + + {renderOrderIcon('potentialBot')} + Date {renderOrderIcon('date')} @@ -165,18 +158,16 @@ export const VisitsTable = ({ {isSelected && } - {supportsBots && ( - - {visit.potentialBot && ( - <> - - - Potentially a visit from a bot or crawler - - - )} - - )} + + {visit.potentialBot && ( + <> + + + Potentially a visit from a bot or crawler + + + )} +
diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index a124368c8..b29dc925d 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -31,7 +31,18 @@ export const ShortUrlsList = ( const serverId = getServerId(selectedServer); const { page } = useParams(); const location = useLocation(); - const [{ tags, search, startDate, endDate, orderBy, tagsMode, excludeBots }, toFirstPage] = useShortUrlsQuery(); + const [filter, toFirstPage] = useShortUrlsQuery(); + const { + tags, + search, + startDate, + endDate, + orderBy, + tagsMode, + excludeBots, + excludePastValidUntil, + excludeMaxVisitsReached, + } = filter; const [actualOrderBy, setActualOrderBy] = useState( // This separated state handling is needed to be able to fall back to settings value, but only once when loaded orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, @@ -67,8 +78,21 @@ export const ShortUrlsList = ( endDate, orderBy: parseOrderByForShlink(actualOrderBy), tagsMode, + excludePastValidUntil, + excludeMaxVisitsReached, }); - }, [page, search, tags, startDate, endDate, actualOrderBy.field, actualOrderBy.dir, tagsMode]); + }, [ + page, + search, + tags, + startDate, + endDate, + actualOrderBy.field, + actualOrderBy.dir, + tagsMode, + excludePastValidUntil, + excludeMaxVisitsReached, + ]); return ( <> diff --git a/src/short-urls/helpers/hooks.ts b/src/short-urls/helpers/hooks.ts index e9bd91f2e..95cc5a43b 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/src/short-urls/helpers/hooks.ts @@ -5,7 +5,7 @@ import { parseQuery, stringifyQuery } from '../../utils/helpers/query'; import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data'; import { orderToString, stringToOrder } from '../../utils/helpers/ordering'; import { TagsFilteringMode } from '../../api/types'; -import { BooleanString, parseBooleanToString } from '../../utils/utils'; +import { BooleanString, parseOptionalBooleanToString } from '../../utils/utils'; interface ShortUrlsQueryCommon { search?: string; @@ -18,12 +18,16 @@ interface ShortUrlsQuery extends ShortUrlsQueryCommon { orderBy?: string; tags?: string; excludeBots?: BooleanString; + excludeMaxVisitsReached?: BooleanString; + excludePastValidUntil?: BooleanString; } interface ShortUrlsFiltering extends ShortUrlsQueryCommon { orderBy?: ShortUrlsOrder; tags: string[]; excludeBots?: boolean; + excludeMaxVisitsReached?: boolean; + excludePastValidUntil?: boolean; } type ToFirstPage = (extra: Partial) => void; @@ -36,7 +40,7 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { const filtering = useMemo( pipe( () => parseQuery(search), - ({ orderBy, tags, excludeBots, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { + ({ orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...rest }): ShortUrlsFiltering => { const parsedOrderBy = orderBy ? stringToOrder(orderBy) : undefined; const parsedTags = tags?.split(',') ?? []; return { @@ -44,18 +48,23 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { orderBy: parsedOrderBy, tags: parsedTags, excludeBots: excludeBots !== undefined ? excludeBots === 'true' : undefined, + excludeMaxVisitsReached: excludeMaxVisitsReached !== undefined ? excludeMaxVisitsReached === 'true' : undefined, + excludePastValidUntil: excludePastValidUntil !== undefined ? excludePastValidUntil === 'true' : undefined, }; }, ), [search], ); const toFirstPageWithExtra = (extra: Partial) => { - const { orderBy, tags, excludeBots, ...mergedFiltering } = { ...filtering, ...extra }; + const merged = { ...filtering, ...extra }; + const { orderBy, tags, excludeBots, excludeMaxVisitsReached, excludePastValidUntil, ...mergedFiltering } = merged; const query: ShortUrlsQuery = { ...mergedFiltering, orderBy: orderBy && orderToString(orderBy), tags: tags.length > 0 ? tags.join(',') : undefined, - excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots), + excludeBots: parseOptionalBooleanToString(excludeBots), + excludeMaxVisitsReached: parseOptionalBooleanToString(excludeMaxVisitsReached), + excludePastValidUntil: parseOptionalBooleanToString(excludePastValidUntil), }; const stringifiedQuery = stringifyQuery(query); const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`; diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index ad366d98f..5c89c8658 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -117,20 +117,24 @@ describe('', () => { }); it.each([ - ['', 'excludeBots=true'], - ['excludeBots=false', 'excludeBots=true'], - ['excludeBots=true', 'excludeBots=false'], - ])('allows to toggle excluding bots through filtering dropdown', async (search, expectedQuery) => { - const { user } = setUp( - search, - Mock.of({ version: '3.4.0' }), - ); - const toggleBots = async (name = 'Exclude bots visits') => { + ['', /Ignore visits from bots/, 'excludeBots=true'], + ['excludeBots=false', /Ignore visits from bots/, 'excludeBots=true'], + ['excludeBots=true', /Ignore visits from bots/, 'excludeBots=false'], + ['', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'], + ['excludeMaxVisitsReached=false', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'], + ['excludeMaxVisitsReached=true', /Exclude with visits reached/, 'excludeMaxVisitsReached=false'], + ['', /Exclude enabled in the past/, 'excludePastValidUntil=true'], + ['excludePastValidUntil=false', /Exclude enabled in the past/, 'excludePastValidUntil=true'], + ['excludePastValidUntil=true', /Exclude enabled in the past/, 'excludePastValidUntil=false'], + ])('allows to toggle filters through filtering dropdown', async (search, menuItemName, expectedQuery) => { + const { user } = setUp(search, Mock.of({ version: '3.4.0' })); + const toggleFilter = async (name: RegExp) => { await user.click(screen.getByRole('button', { name: 'Filters' })); - await user.click(await screen.findByRole('menuitem', { name })); + await waitFor(() => screen.findByRole('menu')); + await user.click(screen.getByRole('menuitem', { name })); }; - await toggleBots(); + await toggleFilter(menuItemName); expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery)); }); From 732d664715f63da24e2ed61de16d3f31a84e22f5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Dec 2022 19:19:46 +0100 Subject: [PATCH 48/56] Fixed coding styles --- src/api/types/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 235ccd5b1..ef51e9b21 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -107,8 +107,7 @@ export interface ShlinkShortUrlsListParams { } export interface ShlinkShortUrlsListNormalizedParams extends - Omit -{ + Omit { orderBy?: string; excludeMaxVisitsReached?: 'true'; excludePastValidUntil?: 'true'; From e1bb0913632cff6f78369d06fa40b4d01d0b7d12 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Dec 2022 19:21:11 +0100 Subject: [PATCH 49/56] Updated changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97339b27a..6af3aaa54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), This feature also comes with a new setting to disable visits from bots by default, both on short URLs lists and visits sections. +* [#760](https://github.com/shlinkio/shlink-web-client/issues/760) Added support to exclude short URLs which have reached the maximum amount of visits, or are valid until a date in the past. + ### Changed * [#753](https://github.com/shlinkio/shlink-web-client/issues/753) Migrated from react-scripts/webpack to vite. * [#770](https://github.com/shlinkio/shlink-web-client/issues/770) Updated to latest dependencies. From 85452cde234b145af9dee20ca8c13b66ea9437ae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 10:34:29 +0100 Subject: [PATCH 50/56] Enhanced visits async thunk so that it wraps both standard async thunk actions and extra ones --- src/visits/reducers/common.ts | 33 ++++++++++---------- test/visits/reducers/domainVisits.test.ts | 15 +++++---- test/visits/reducers/nonOrphanVisits.test.ts | 18 ++++++----- test/visits/reducers/orphanVisits.test.ts | 15 +++++---- test/visits/reducers/shortUrlVisits.test.ts | 18 ++++++----- test/visits/reducers/tagVisits.test.ts | 15 +++++---- 6 files changed, 58 insertions(+), 56 deletions(-) diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index f64078cdb..b3abfddd2 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -29,11 +29,11 @@ interface VisitsAsyncThunkOptions( { typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, ) => { - const progressChangedAction = createAction(`${typePrefix}/progressChanged`); - const largeAction = createAction(`${typePrefix}/large`); - const fallbackToIntervalAction = createAction(`${typePrefix}/fallbackToInterval`); + const progressChanged = createAction(`${typePrefix}/progressChanged`); + const large = createAction(`${typePrefix}/large`); + const fallbackToInterval = createAction(`${typePrefix}/fallbackToInterval`); - const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise => { + const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise> => { const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); const loadVisitsInParallel = async (pages: number[]): Promise => @@ -46,7 +46,7 @@ export const createVisitsAsyncThunk = PARALLEL_REQUESTS_COUNT) { - dispatch(largeAction()); + dispatch(large()); } return data.concat(await loadPagesBlocks(pagesBlocks)); @@ -77,13 +77,14 @@ export const createVisitsAsyncThunk = >( { name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions, ) => { - const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator; + const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator; const { reducer, actions } = createSlice({ name, initialState, @@ -115,17 +116,17 @@ export const createVisitsReducer = ({ ...state, cancelLoad: true }), }, extraReducers: (builder) => { - builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true })); - builder.addCase(asyncThunk.rejected, (_, { error }) => ( + builder.addCase(pending, () => ({ ...initialState, loading: true })); + builder.addCase(rejected, (_, { error }) => ( { ...initialState, error: true, errorData: parseApiError(error) } )); - builder.addCase(asyncThunk.fulfilled, (state, { payload }) => ( + builder.addCase(fulfilled, (state, { payload }) => ( { ...state, ...payload, loading: false, loadingLarge: false, error: false } )); - builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true })); - builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress })); - builder.addCase(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => ( + builder.addCase(large, (state) => ({ ...state, loadingLarge: true })); + builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress })); + builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } )); diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index b7024e0d8..2f508a88b 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -21,9 +21,8 @@ describe('domainVisitsReducer', () => { const visitsMocks = rangeOf(2, () => Mock.all()); const getDomainVisitsCall = jest.fn(); const buildApiClientMock = () => Mock.of({ getDomainVisits: getDomainVisitsCall }); - const creator = getDomainVisitsCreator(buildApiClientMock); - const { asyncThunk: getDomainVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetVisits: cancelGetDomainVisits } = domainVisitsReducerCreator(creator); + const getDomainVisits = getDomainVisitsCreator(buildApiClientMock); + const { reducer, cancelGetVisits: cancelGetDomainVisits } = domainVisitsReducerCreator(getDomainVisits); beforeEach(jest.clearAllMocks); @@ -36,7 +35,7 @@ describe('domainVisitsReducer', () => { }); it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => { - const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); + const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: getDomainVisits.large.toString() }); expect(loadingLarge).toEqual(true); }); @@ -130,7 +129,7 @@ describe('domainVisitsReducer', () => { }); it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); + const state = reducer(undefined, { type: getDomainVisits.progressChanged.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -139,7 +138,7 @@ describe('domainVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + { type: getDomainVisits.fallbackToInterval.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -198,12 +197,12 @@ describe('domainVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(now, 20)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + { type: getDomainVisits.fallbackToInterval.toString(), payload: 'last30Days' }, 3, ], [ [Mock.of({ date: formatISO(subDays(now, 100)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last180Days' }, + { type: getDomainVisits.fallbackToInterval.toString(), payload: 'last180Days' }, 3, ], [[], expect.objectContaining({ type: getDomainVisits.fulfilled.toString() }), 2], diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 89dc2671a..33ad5db53 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -19,9 +19,8 @@ describe('nonOrphanVisitsReducer', () => { const visitsMocks = rangeOf(2, () => Mock.all()); const getNonOrphanVisitsCall = jest.fn(); const buildShlinkApiClient = () => Mock.of({ getNonOrphanVisits: getNonOrphanVisitsCall }); - const creator = getNonOrphanVisitsCreator(buildShlinkApiClient); - const { asyncThunk: getNonOrphanVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetVisits: cancelGetNonOrphanVisits } = nonOrphanVisitsReducerCreator(creator); + const getNonOrphanVisits = getNonOrphanVisitsCreator(buildShlinkApiClient); + const { reducer, cancelGetVisits: cancelGetNonOrphanVisits } = nonOrphanVisitsReducerCreator(getNonOrphanVisits); beforeEach(jest.clearAllMocks); @@ -34,7 +33,10 @@ describe('nonOrphanVisitsReducer', () => { }); it('returns loadingLarge on GET_NON_ORPHAN_VISITS_LARGE', () => { - const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); + const { loadingLarge } = reducer( + buildState({ loadingLarge: false }), + { type: getNonOrphanVisits.large.toString() }, + ); expect(loadingLarge).toEqual(true); }); @@ -110,7 +112,7 @@ describe('nonOrphanVisitsReducer', () => { }); it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); + const state = reducer(undefined, { type: getNonOrphanVisits.progressChanged.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -118,7 +120,7 @@ describe('nonOrphanVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + { type: getNonOrphanVisits.fallbackToInterval.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -178,12 +180,12 @@ describe('nonOrphanVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(now, 5)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + { type: getNonOrphanVisits.fallbackToInterval.toString(), payload: 'last7Days' }, 3, ], [ [Mock.of({ date: formatISO(subDays(now, 200)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last365Days' }, + { type: getNonOrphanVisits.fallbackToInterval.toString(), payload: 'last365Days' }, 3, ], [[], expect.objectContaining({ type: getNonOrphanVisits.fulfilled.toString() }), 2], diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 083b7fc47..27abb4c46 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -19,9 +19,8 @@ describe('orphanVisitsReducer', () => { const visitsMocks = rangeOf(2, () => Mock.all()); const getOrphanVisitsCall = jest.fn(); const buildShlinkApiClientMock = () => Mock.of({ getOrphanVisits: getOrphanVisitsCall }); - const creator = getOrphanVisitsCreator(buildShlinkApiClientMock); - const { asyncThunk: getOrphanVisits, largeAction, progressChangedAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetVisits: cancelGetOrphanVisits } = orphanVisitsReducerCreator(creator); + const getOrphanVisits = getOrphanVisitsCreator(buildShlinkApiClientMock); + const { reducer, cancelGetVisits: cancelGetOrphanVisits } = orphanVisitsReducerCreator(getOrphanVisits); beforeEach(jest.clearAllMocks); @@ -34,7 +33,7 @@ describe('orphanVisitsReducer', () => { }); it('returns loadingLarge on GET_ORPHAN_VISITS_LARGE', () => { - const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); + const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: getOrphanVisits.large.toString() }); expect(loadingLarge).toEqual(true); }); @@ -110,7 +109,7 @@ describe('orphanVisitsReducer', () => { }); it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); + const state = reducer(undefined, { type: getOrphanVisits.progressChanged.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -118,7 +117,7 @@ describe('orphanVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + { type: getOrphanVisits.fallbackToInterval.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -176,12 +175,12 @@ describe('orphanVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(now, 5)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + { type: getOrphanVisits.fallbackToInterval.toString(), payload: 'last7Days' }, 3, ], [ [Mock.of({ date: formatISO(subDays(now, 200)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last365Days' }, + { type: getOrphanVisits.fallbackToInterval.toString(), payload: 'last365Days' }, 3, ], [[], expect.objectContaining({ type: getOrphanVisits.fulfilled.toString() }), 2], diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index 6a2b74522..d59b7621e 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -19,9 +19,8 @@ describe('shortUrlVisitsReducer', () => { const visitsMocks = rangeOf(2, () => Mock.all()); const getShortUrlVisitsCall = jest.fn(); const buildApiClientMock = () => Mock.of({ getShortUrlVisits: getShortUrlVisitsCall }); - const creator = getShortUrlVisitsCreator(buildApiClientMock); - const { asyncThunk: getShortUrlVisits, largeAction, progressChangedAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetVisits: cancelGetShortUrlVisits } = shortUrlVisitsReducerCreator(creator); + const getShortUrlVisits = getShortUrlVisitsCreator(buildApiClientMock); + const { reducer, cancelGetVisits: cancelGetShortUrlVisits } = shortUrlVisitsReducerCreator(getShortUrlVisits); beforeEach(jest.clearAllMocks); @@ -34,7 +33,10 @@ describe('shortUrlVisitsReducer', () => { }); it('returns loadingLarge on GET_SHORT_URL_VISITS_LARGE', () => { - const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); + const { loadingLarge } = reducer( + buildState({ loadingLarge: false }), + { type: getShortUrlVisits.large.toString() }, + ); expect(loadingLarge).toEqual(true); }); @@ -130,7 +132,7 @@ describe('shortUrlVisitsReducer', () => { }); it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); + const state = reducer(undefined, { type: getShortUrlVisits.progressChanged.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -138,7 +140,7 @@ describe('shortUrlVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + { type: getShortUrlVisits.fallbackToInterval.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -220,12 +222,12 @@ describe('shortUrlVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(now, 5)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + { type: getShortUrlVisits.fallbackToInterval.toString(), payload: 'last7Days' }, 3, ], [ [Mock.of({ date: formatISO(subDays(now, 200)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last365Days' }, + { type: getShortUrlVisits.fallbackToInterval.toString(), payload: 'last365Days' }, 3, ], [[], expect.objectContaining({ type: getShortUrlVisits.fulfilled.toString() }), 2], diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index c96eca442..e8e4125c9 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -19,9 +19,8 @@ describe('tagVisitsReducer', () => { const visitsMocks = rangeOf(2, () => Mock.all()); const getTagVisitsCall = jest.fn(); const buildShlinkApiClientMock = () => Mock.of({ getTagVisits: getTagVisitsCall }); - const creator = getTagVisitsCreator(buildShlinkApiClientMock); - const { asyncThunk: getTagVisits, fallbackToIntervalAction, largeAction, progressChangedAction } = creator; - const { reducer, cancelGetVisits: cancelGetTagVisits } = tagVisitsReducerCreator(creator); + const getTagVisits = getTagVisitsCreator(buildShlinkApiClientMock); + const { reducer, cancelGetVisits: cancelGetTagVisits } = tagVisitsReducerCreator(getTagVisits); beforeEach(jest.clearAllMocks); @@ -34,7 +33,7 @@ describe('tagVisitsReducer', () => { }); it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => { - const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); + const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: getTagVisits.large.toString() }); expect(loadingLarge).toEqual(true); }); @@ -130,13 +129,13 @@ describe('tagVisitsReducer', () => { }); it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); + const state = reducer(undefined, { type: getTagVisits.progressChanged.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => { const fallbackInterval: DateInterval = 'last30Days'; - const state = reducer(undefined, { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }); + const state = reducer(undefined, { type: getTagVisits.fallbackToInterval.toString(), payload: fallbackInterval }); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); @@ -194,12 +193,12 @@ describe('tagVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(now, 20)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + { type: getTagVisits.fallbackToInterval.toString(), payload: 'last30Days' }, 3, ], [ [Mock.of({ date: formatISO(subDays(now, 100)) })], - { type: fallbackToIntervalAction.toString(), payload: 'last180Days' }, + { type: getTagVisits.fallbackToInterval.toString(), payload: 'last180Days' }, 3, ], [[], expect.objectContaining({ type: getTagVisits.fulfilled.toString() }), 2], From 84f97278367ec52796a9f2230bffa115a4127ddc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 10:35:58 +0100 Subject: [PATCH 51/56] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af3aaa54..de8853b8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed * [#753](https://github.com/shlinkio/shlink-web-client/issues/753) Migrated from react-scripts/webpack to vite. * [#770](https://github.com/shlinkio/shlink-web-client/issues/770) Updated to latest dependencies. +* [#741](https://github.com/shlinkio/shlink-web-client/issues/741) Improved `visitsAsyncThunk`, making it wrap pending/fulfilled/rejected actions, as well as custom ones, in a type-safe way. ### Deprecated * *Nothing* From 4517f38680294a27fa1f5fe633d4962cfd348204 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 10:43:03 +0100 Subject: [PATCH 52/56] Fixed injection of visits loaders --- src/visits/services/provideServices.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index ba1bbcb22..bff165482 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -54,24 +54,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('VisitsParser', () => visitsParser); // Actions - bottle.serviceFactory('getShortUrlVisitsCreator', getShortUrlVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('getShortUrlVisits', prop('asyncThunk'), 'getShortUrlVisitsCreator'); + bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator'); - bottle.serviceFactory('getTagVisitsCreator', getTagVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('getTagVisits', prop('asyncThunk'), 'getTagVisitsCreator'); + bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator'); - bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); + bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator'); - bottle.serviceFactory('getOrphanVisitsCreator', getOrphanVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('getOrphanVisits', prop('asyncThunk'), 'getOrphanVisitsCreator'); + bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator'); - bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator'); + bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator'); bottle.serviceFactory('createNewVisits', () => createNewVisits); @@ -81,19 +76,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator'); - bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisitsCreator'); + bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisits'); bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator'); - bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisitsCreator'); + bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisits'); bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator'); - bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisitsCreator'); + bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisits'); bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator'); - bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisitsCreator'); + bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisits'); bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator'); - bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisitsCreator'); + bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisits'); bottle.serviceFactory('tagVisitsReducer', prop('reducer'), 'tagVisitsReducerCreator'); }; From 2badd2b7433254ad1ea1e5585ff3b98c5ff1a826 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 10:47:15 +0100 Subject: [PATCH 53/56] Fixed warning in tests --- test/short-urls/Paginator.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/short-urls/Paginator.test.tsx b/test/short-urls/Paginator.test.tsx index 364c81966..a40a62d8a 100644 --- a/test/short-urls/Paginator.test.tsx +++ b/test/short-urls/Paginator.test.tsx @@ -21,7 +21,7 @@ describe('', () => { ])('renders an empty gap if the number of pages is below 2', (paginator) => { const { container } = setUp(paginator); - expect(container.firstChild).toBeEmpty(); + expect(container.firstChild).toBeEmptyDOMElement(); expect(container.firstChild).toHaveClass('pb-3'); }); From 91f4d096087d15d9e197aa255c1086ced1980573 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 16:42:04 +0100 Subject: [PATCH 54/56] Ensured a recconnection happens to selected server when its params are edited --- src/common/MenuLayout.tsx | 1 - src/servers/EditServer.tsx | 8 ++++++-- src/servers/helpers/ServerError.tsx | 2 +- src/servers/reducers/selectedServer.ts | 10 +++++----- src/utils/helpers/hooks.ts | 7 ++++++- test/servers/EditServer.test.tsx | 6 ++++-- vite.config.ts | 1 + 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 0fc05448d..58f7da2d3 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -38,7 +38,6 @@ export const MenuLayout = ( useEffect(() => hideSidebar(), [location]); useEffect(() => { showContent && sidebarPresent(); - return () => sidebarNotPresent(); }, []); diff --git a/src/servers/EditServer.tsx b/src/servers/EditServer.tsx index c89b44689..e95b4daa5 100644 --- a/src/servers/EditServer.tsx +++ b/src/servers/EditServer.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { Button } from 'reactstrap'; import { NoMenuLayout } from '../common/NoMenuLayout'; -import { useGoBack } from '../utils/helpers/hooks'; +import { useGoBack, useParsedQuery } from '../utils/helpers/hooks'; import { ServerForm } from './helpers/ServerForm'; import { withSelectedServer } from './helpers/withSelectedServer'; import { isServerWithId, ServerData } from './data'; @@ -10,8 +10,11 @@ interface EditServerProps { editServer: (serverId: string, serverData: ServerData) => void; } -export const EditServer = (ServerError: FC) => withSelectedServer(({ editServer, selectedServer }) => { +export const EditServer = (ServerError: FC) => withSelectedServer(( + { editServer, selectedServer, selectServer }, +) => { const goBack = useGoBack(); + const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>(); if (!isServerWithId(selectedServer)) { return null; @@ -19,6 +22,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer { editServer(selectedServer.id, serverData); + reconnect === 'true' && selectServer(selectedServer.id); goBack(); }; diff --git a/src/servers/helpers/ServerError.tsx b/src/servers/helpers/ServerError.tsx index 6f605969c..f133d4944 100644 --- a/src/servers/helpers/ServerError.tsx +++ b/src/servers/helpers/ServerError.tsx @@ -37,7 +37,7 @@ export const ServerError = (DeleteServerButton: FC): FC
Alternatively, if you think you may have miss-configured this server, you can remove it or  - edit it. + edit it.
)} diff --git a/src/servers/reducers/selectedServer.ts b/src/servers/reducers/selectedServer.ts index 3415a15c3..b69647f0c 100644 --- a/src/servers/reducers/selectedServer.ts +++ b/src/servers/reducers/selectedServer.ts @@ -1,7 +1,7 @@ import { createAction, createListenerMiddleware, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { identity, memoizeWith, pipe } from 'ramda'; +import { memoizeWith, pipe } from 'ramda'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; -import { isReachableServer, SelectedServer } from '../data'; +import { isReachableServer, SelectedServer, ServerWithId } from '../data'; import { ShlinkHealth } from '../../api/types'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; @@ -18,8 +18,8 @@ const versionToSemVer = pipe( ); const getServerVersion = memoizeWith( - identity, - async (_serverId: string, health: () => Promise) => health().then(({ version }) => ({ + (server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`, + async (_server: ServerWithId, health: () => Promise) => health().then(({ version }) => ({ version: versionToSemVer(version), printableVersion: versionToPrintable(version), })), @@ -43,7 +43,7 @@ export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr try { const { health } = buildShlinkApiClient(selectedServer); - const { version, printableVersion } = await getServerVersion(serverId, health); + const { version, printableVersion } = await getServerVersion(selectedServer, health); return { ...selectedServer, diff --git a/src/utils/helpers/hooks.ts b/src/utils/helpers/hooks.ts index 2e033cd8d..56406afae 100644 --- a/src/utils/helpers/hooks.ts +++ b/src/utils/helpers/hooks.ts @@ -1,6 +1,6 @@ import { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react'; import { useSwipeable as useReactSwipeable } from 'react-swipeable'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { v4 as uuid } from 'uuid'; import { parseQuery, stringifyQuery } from './query'; @@ -82,6 +82,11 @@ export const useGoBack = () => { return () => navigate(-1); }; +export const useParsedQuery = (): T => { + const { search } = useLocation(); + return parseQuery(search); +}; + export const useDomId = (): string => { const { current: id } = useRef(`dom-${uuid()}`); return id; diff --git a/test/servers/EditServer.test.tsx b/test/servers/EditServer.test.tsx index 771a6210e..c5181bc0e 100644 --- a/test/servers/EditServer.test.tsx +++ b/test/servers/EditServer.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, screen } from '@testing-library/react'; import { Mock } from 'ts-mockery'; -import { useNavigate } from 'react-router-dom'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; import { EditServer as editServerConstruct } from '../../src/servers/EditServer'; import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { renderWithEvents } from '../__helpers__/setUpTest'; @@ -19,7 +19,9 @@ describe('', () => { }); const EditServer = editServerConstruct(ServerError); const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => renderWithEvents( - , + + + , ); beforeEach(() => { diff --git a/vite.config.ts b/vite.config.ts index b74d34355..2b0cf2616 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import pack from './package.json'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), VitePWA({ + mode: process.env.NODE_ENV === 'development' ? 'development' : 'production', strategies: 'injectManifest', srcDir: './src', filename: 'service-worker.ts', From 27099aa7fbfc54db22c361f722d91d4d43455c2a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 16:43:19 +0100 Subject: [PATCH 55/56] Updated changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de8853b8e..1a6754ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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). -## [Unreleased] +## [3.9.0] - 2022-12-31 ### Added * [#750](https://github.com/shlinkio/shlink-web-client/issues/750) Added new icon indicators telling if a short URL can be normally visited, it received the max amount of visits, is still not enabled, etc. * [#764](https://github.com/shlinkio/shlink-web-client/issues/764) Added support to exclude visits from visits on short URLs list when consuming Shlink 3.4.0. @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0. ### Fixed -* *Nothing* +* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values. ## [3.8.2] - 2022-12-17 From 37ac6cebc12475d0f7b25fb23c0d673bc9212071 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 31 Dec 2022 16:56:22 +0100 Subject: [PATCH 56/56] Fixed selectedServer reucer test --- test/servers/reducers/selectedServer.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/servers/reducers/selectedServer.test.ts b/test/servers/reducers/selectedServer.test.ts index a8db0c958..bbb93798c 100644 --- a/test/servers/reducers/selectedServer.test.ts +++ b/test/servers/reducers/selectedServer.test.ts @@ -39,11 +39,12 @@ describe('selectedServerReducer', () => { }); describe('selectServer', () => { - const selectedServer = { - id: 'abc123', - }; const version = '1.19.0'; - const createGetStateMock = (id: string) => jest.fn().mockReturnValue({ servers: { [id]: selectedServer } }); + const createGetStateMock = (id: string) => jest.fn().mockReturnValue({ + servers: { + [id]: { id }, + }, + }); it.each([ [version, version, `v${version}`], @@ -53,7 +54,7 @@ describe('selectedServerReducer', () => { const id = uuid(); const getState = createGetStateMock(id); const expectedSelectedServer = { - ...selectedServer, + id, version: expectedVersion, printableVersion: expectedPrintableVersion, }; @@ -84,7 +85,7 @@ describe('selectedServerReducer', () => { it('dispatches error when health endpoint fails', async () => { const id = uuid(); const getState = createGetStateMock(id); - const expectedSelectedServer = Mock.of({ ...selectedServer, serverNotReachable: true }); + const expectedSelectedServer = Mock.of({ id, serverNotReachable: true }); health.mockRejectedValue({});