From 6c2f5b99ac941c316654fdb88fb508a07b978e7b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Aug 2021 19:40:53 +0200 Subject: [PATCH 01/76] Added dynamic title on hover for tags with a very long title --- CHANGELOG.md | 17 +++++++++++++++++ src/tags/TagCard.tsx | 20 ++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 363b2b14..2a740759 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 +* [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title. + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.2.0] - 2021-07-12 ### Added * [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars: diff --git a/src/tags/TagCard.tsx b/src/tags/TagCard.tsx index b72bca0e..e8036b04 100644 --- a/src/tags/TagCard.tsx +++ b/src/tags/TagCard.tsx @@ -1,7 +1,7 @@ import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons'; -import { FC } from 'react'; +import { FC, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { prettify } from '../utils/helpers/numbers'; import { useToggle } from '../utils/helpers/hooks'; @@ -20,6 +20,8 @@ export interface TagCardProps { toggle: () => void; } +const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth; + const TagCard = ( DeleteTagConfirmModal: FC, EditTagModal: FC, @@ -28,10 +30,18 @@ const TagCard = ( ) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => { const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isEditModalOpen, toggleEdit ] = useToggle(); + const [ hasTitle,, displayTitle ] = useToggle(); + const titleRef = useRef(); const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${tag}`; + useEffect(() => { + if (isTruncated(titleRef.current)) { + displayTitle(); + } + }, [ titleRef.current ]); + return ( @@ -41,7 +51,13 @@ const TagCard = ( -
+
{ + titleRef.current = el ?? undefined; + }} + > {tag} From b8a7dccf92fce936e2e860daa1b5bfb2d76c7f13 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 09:45:14 +0200 Subject: [PATCH 02/76] Ensured TagsSelector does not allow duplicated tags, and allows adding multiple coma-separated tags at once --- src/tags/helpers/TagsSelector.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index 21cd1cb8..c7041576 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -44,13 +44,18 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( addOnBlur placeholderText={placeholder} minQueryLength={1} + delimiters={[ 'Enter', 'Tab', ',' ]} onDelete={(removedTagIndex) => { const tagsCopy = [ ...selectedTags ]; tagsCopy.splice(removedTagIndex, 1); onChange(tagsCopy); }} - onAddition={({ name: newTag }) => onChange([ ...selectedTags, newTag.toLowerCase() ])} + onAddition={({ name: newTag }) => onChange( + // * Avoid duplicated tags (thanks to the Set), + // * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once. + [ ...new Set([ ...selectedTags, ...newTag.toLowerCase().split(',') ]) ], + )} /> ); }; From 4417a17d5c6134778ac2079a8926c96baaa4b324 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 09:49:01 +0200 Subject: [PATCH 03/76] Improved TagsSelector component test, covering different logic while adding tags --- test/tags/helpers/TagsSelector.test.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/tags/helpers/TagsSelector.test.tsx b/test/tags/helpers/TagsSelector.test.tsx index 92725838..3c3739cb 100644 --- a/test/tags/helpers/TagsSelector.test.tsx +++ b/test/tags/helpers/TagsSelector.test.tsx @@ -49,10 +49,14 @@ describe('', () => { ]); }); - it('invokes onChange when new tags are added', () => { - wrapper.simulate('addition', { name: 'The-New-Tag' }); + it.each([ + [ 'The-New-Tag', [ ...tags, 'the-new-tag' ]], + [ 'comma,separated,tags', [ ...tags, 'comma', 'separated', 'tags' ]], + [ 'foo', tags ], + ])('invokes onChange when new tags are added', (newTag, expectedTags) => { + wrapper.simulate('addition', { name: newTag }); - expect(onChange).toHaveBeenCalledWith([ ...tags, 'the-new-tag' ]); + expect(onChange).toHaveBeenCalledWith(expectedTags); }); it.each([ From 802982327118b750b91c248ca47fbe2168b52420 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 09:50:27 +0200 Subject: [PATCH 04/76] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a740759..128b91bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added * [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title. +* [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags. ### Changed * *Nothing* From 9f02bc64963616ba76968317f51f8a06b6b5cbe5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 10:55:58 +0200 Subject: [PATCH 05/76] Added new settings to determine how to search on tags during short URL creation, and how many suggestions to display --- src/settings/reducers/settings.ts | 4 ++++ src/tags/helpers/TagsSelector.tsx | 14 ++++++++++++-- src/tags/services/provideServices.ts | 2 +- test/tags/helpers/TagsSelector.test.tsx | 9 ++++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 0ba6f106..df405e2a 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -17,8 +17,12 @@ interface RealTimeUpdatesSettings { interval?: number; } +type TagFilteringMode = 'startsWith' | 'includes'; + export interface ShortUrlCreationSettings { validateUrls: boolean; + tagFilteringMode?: TagFilteringMode; + maxTagSuggestions?: number; } export interface UiSettings { diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index c7041576..83e62b15 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete'; import ColorGenerator from '../../utils/services/ColorGenerator'; +import { Settings } from '../../settings/reducers/settings'; import { TagsList } from '../reducers/tagsList'; import TagBullet from './TagBullet'; import Tag from './Tag'; @@ -14,17 +15,20 @@ export interface TagsSelectorProps { interface TagsSelectorConnectProps extends TagsSelectorProps { listTags: Function; tagsList: TagsList; + settings: Settings; } const toComponentTag = (tag: string) => ({ id: tag, name: tag }); const TagsSelector = (colorGenerator: ColorGenerator) => ( - { selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps, + { selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps, ) => { useEffect(() => { listTags(); }, []); + const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith'; + const maxSuggestions = settings.shortUrlCreation?.maxTagSuggestions; const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => ; const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( @@ -42,9 +46,15 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( suggestionComponent={ReactTagsSuggestion} allowNew addOnBlur - placeholderText={placeholder} + placeholderText={placeholder ?? 'Add tags to the URL'} minQueryLength={1} + maxSuggestionsLength={maxSuggestions} delimiters={[ 'Enter', 'Tab', ',' ]} + suggestionsTransform={ + searchMode === 'includes' + ? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query)) + : undefined + } onDelete={(removedTagIndex) => { const tagsCopy = [ ...selectedTags ]; diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index 3e71c32f..ba5951c1 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -12,7 +12,7 @@ import { ConnectDecorator } from '../../container/types'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); - bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ])); + bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ])); bottle.serviceFactory( 'TagCard', diff --git a/test/tags/helpers/TagsSelector.test.tsx b/test/tags/helpers/TagsSelector.test.tsx index 3c3739cb..91934aca 100644 --- a/test/tags/helpers/TagsSelector.test.tsx +++ b/test/tags/helpers/TagsSelector.test.tsx @@ -3,6 +3,7 @@ import { Mock } from 'ts-mockery'; import createTagsSelector from '../../../src/tags/helpers/TagsSelector'; import ColorGenerator from '../../../src/utils/services/ColorGenerator'; import { TagsList } from '../../../src/tags/reducers/tagsList'; +import { Settings } from '../../../src/settings/reducers/settings'; describe('', () => { const onChange = jest.fn(); @@ -14,7 +15,13 @@ describe('', () => { beforeEach(jest.clearAllMocks); beforeEach(() => { wrapper = shallow( - , + ()} + listTags={jest.fn()} + onChange={onChange} + />, ); }); From 322396a366564483f2ce621e52586835a36f1b50 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 18:13:13 +0200 Subject: [PATCH 06/76] Allowed to dynamically determine how short URL suggestions are calculated --- src/settings/Settings.tsx | 4 +- src/settings/ShortUrlCreation.tsx | 71 ++++++++++++++++++------- src/settings/reducers/settings.ts | 5 +- src/tags/helpers/TagsSelector.tsx | 2 - test/settings/ShortUrlCreation.test.tsx | 55 +++++++++++++++---- 5 files changed, 102 insertions(+), 35 deletions(-) diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 294115a0..fc8ea340 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, , ], // eslint-disable-line react/jsx-key - [ , ], // eslint-disable-line react/jsx-key + [ , ], // eslint-disable-line react/jsx-key + [ , ], // eslint-disable-line react/jsx-key ]} /> diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx index b91ed500..f180a3ed 100644 --- a/src/settings/ShortUrlCreation.tsx +++ b/src/settings/ShortUrlCreation.tsx @@ -1,29 +1,62 @@ -import { FC } from 'react'; -import { FormGroup } from 'reactstrap'; +import { FC, ReactNode } from 'react'; +import { DropdownItem, FormGroup } from 'reactstrap'; import { SimpleCard } from '../utils/SimpleCard'; import ToggleSwitch from '../utils/ToggleSwitch'; -import { Settings, ShortUrlCreationSettings } from './reducers/settings'; +import { DropdownBtn } from '../utils/DropdownBtn'; +import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings'; interface ShortUrlCreationProps { settings: Settings; setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void; } -export const ShortUrlCreation: FC = ( - { settings: { shortUrlCreation }, setShortUrlCreationSettings }, -) => ( - - - setShortUrlCreationSettings({ validateUrls })} - > - By default, request validation on long URLs when creating new short URLs. +const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string => + tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input'; +const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => + tagFilteringMode === 'includes' + ? <>The list of suggested tags will contain existing ones including provided input. + : <>The list of suggested tags will contain existing ones starting with provided input.; + +export const ShortUrlCreation: FC = ({ settings, setShortUrlCreationSettings }) => { + const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false }; + const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings( + { ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode }, + ); + + return ( + + + setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })} + > + By default, request validation on long URLs when creating new short URLs. + + The initial state of the Validate URL checkbox will + be {shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}. + + + + + + + + {tagFilteringModeText('startsWith')} + + + {tagFilteringModeText('includes')} + + - The initial state of the Validate URL checkbox will - be {shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}. + {tagFilteringModeHint(shortUrlCreation.tagFilteringMode)} - - - -); + + + ); +} diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index df405e2a..f052893c 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -17,12 +17,11 @@ interface RealTimeUpdatesSettings { interval?: number; } -type TagFilteringMode = 'startsWith' | 'includes'; +export type TagFilteringMode = 'startsWith' | 'includes'; export interface ShortUrlCreationSettings { validateUrls: boolean; - tagFilteringMode?: TagFilteringMode; - maxTagSuggestions?: number; + tagFilteringMode?: TagFilteringMode } export interface UiSettings { diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index 83e62b15..8b585fe2 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -28,7 +28,6 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( }, []); const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith'; - const maxSuggestions = settings.shortUrlCreation?.maxTagSuggestions; const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => ; const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( @@ -48,7 +47,6 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( addOnBlur placeholderText={placeholder ?? 'Add tags to the URL'} minQueryLength={1} - maxSuggestionsLength={maxSuggestions} delimiters={[ 'Enter', 'Tab', ',' ]} suggestionsTransform={ searchMode === 'includes' diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx index 1cb92d9a..aee7df18 100644 --- a/test/settings/ShortUrlCreation.test.tsx +++ b/test/settings/ShortUrlCreation.test.tsx @@ -3,6 +3,8 @@ import { Mock } from 'ts-mockery'; import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings'; import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation'; import ToggleSwitch from '../../src/utils/ToggleSwitch'; +import { DropdownBtn } from '../../src/utils/DropdownBtn'; +import { DropdownItem } from 'reactstrap'; describe('', () => { let wrapper: ShallowWrapper; @@ -25,13 +27,41 @@ describe('', () => { [{ validateUrls: true }, true ], [{ validateUrls: false }, false ], [ undefined, false ], - ])('switch is toggled if option is true', (shortUrlCreation, expectedChecked) => { + ])('URL validation switch is toggled if option is true', (shortUrlCreation, expectedChecked) => { const wrapper = createWrapper(shortUrlCreation); const toggle = wrapper.find(ToggleSwitch); expect(toggle.prop('checked')).toEqual(expectedChecked); }); + it.each([ + [{ validateUrls: true }, 'checkbox will be checked' ], + [{ validateUrls: false }, 'checkbox will be unchecked' ], + [ undefined, 'checkbox will be unchecked' ], + ])('shows expected helper text for URL validation', (shortUrlCreation, expectedText) => { + const wrapper = createWrapper(shortUrlCreation); + const text = wrapper.find('.form-text').first(); + + expect(text.text()).toContain(expectedText); + }); + + it.each([ + [ { tagFilteringMode: 'includes' } as ShortUrlCreationSettings, 'Suggest tags including input', 'including' ], + [ + { tagFilteringMode: 'startsWith' } as ShortUrlCreationSettings, + 'Suggest tags starting with input', + 'starting with', + ], + [ undefined, 'Suggest tags starting with input', 'starting with' ], + ])('shows expected texts for tags suggestions', (shortUrlCreation, expectedText, expectedHint) => { + const wrapper = createWrapper(shortUrlCreation); + const hintText = wrapper.find('.form-text').last(); + const dropdown = wrapper.find(DropdownBtn); + + expect(dropdown.prop('text')).toEqual(expectedText); + expect(hintText.text()).toContain(expectedHint); + }); + it.each([[ true ], [ false ]])('invokes setShortUrlCreationSettings when toggle value changes', (validateUrls) => { const wrapper = createWrapper(); const toggle = wrapper.find(ToggleSwitch); @@ -41,14 +71,21 @@ describe('', () => { expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls }); }); - it.each([ - [{ validateUrls: true }, 'checkbox will be checked' ], - [{ validateUrls: false }, 'checkbox will be unchecked' ], - [ undefined, 'checkbox will be unchecked' ], - ])('shows expected helper text', (shortUrlCreation, expectedText) => { - const wrapper = createWrapper(shortUrlCreation); - const text = wrapper.find('.form-text'); + it('invokes setShortUrlCreationSettings when dropdown value changes', () => { + const wrapper = createWrapper(); + const firstDropdownItem = wrapper.find(DropdownItem).first(); + const secondDropdownItem = wrapper.find(DropdownItem).last(); - expect(text.text()).toContain(expectedText); + expect(setShortUrlCreationSettings).not.toHaveBeenCalled(); + + firstDropdownItem.simulate('click'); + expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining( + { tagFilteringMode: 'startsWith' }, + )); + + secondDropdownItem.simulate('click'); + expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining( + { tagFilteringMode: 'includes' }, + )); }); }); From edd536cc1eec29649f1b2d1f1797fd7bed8316ae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 18:17:05 +0200 Subject: [PATCH 07/76] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128b91bd..ccd3121f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added * [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title. * [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags. +* [#463](https://github.com/shlinkio/shlink-web-client/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured: + + * `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far. + * `includes`: Suggests tags that contain the input. ### Changed * *Nothing* From 3484e74559963eec0ac1706aa02bc725d988bc7c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 18:21:36 +0200 Subject: [PATCH 08/76] Fixed coding styles --- src/settings/ShortUrlCreation.tsx | 2 +- src/settings/reducers/settings.ts | 2 +- test/settings/ShortUrlCreation.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx index f180a3ed..2caaa329 100644 --- a/src/settings/ShortUrlCreation.tsx +++ b/src/settings/ShortUrlCreation.tsx @@ -59,4 +59,4 @@ export const ShortUrlCreation: FC = ({ settings, setShort ); -} +}; diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index f052893c..a798697b 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -21,7 +21,7 @@ export type TagFilteringMode = 'startsWith' | 'includes'; export interface ShortUrlCreationSettings { validateUrls: boolean; - tagFilteringMode?: TagFilteringMode + tagFilteringMode?: TagFilteringMode; } export interface UiSettings { diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx index aee7df18..3a61f5dd 100644 --- a/test/settings/ShortUrlCreation.test.tsx +++ b/test/settings/ShortUrlCreation.test.tsx @@ -1,10 +1,10 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; +import { DropdownItem } from 'reactstrap'; import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings'; import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation'; import ToggleSwitch from '../../src/utils/ToggleSwitch'; import { DropdownBtn } from '../../src/utils/DropdownBtn'; -import { DropdownItem } from 'reactstrap'; describe('', () => { let wrapper: ShallowWrapper; From eb90aa227428cedf8042446012858315c21e3824 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 13:12:07 +0200 Subject: [PATCH 09/76] Added support to download QR codes to the QR code modal --- src/common/services/ImageDownloader.ts | 13 ++++++++++ src/common/services/provideServices.ts | 5 ++++ src/servers/services/ServersExporter.ts | 2 +- src/short-urls/helpers/QrCodeModal.tsx | 25 ++++++++++++++++---- src/short-urls/services/provideServices.ts | 2 +- src/utils/helpers/{csv.ts => files.ts} | 11 ++++++--- src/visits/services/VisitsExporter.ts | 2 +- test/short-urls/helpers/QrCodeModal.test.tsx | 18 +++++++++++--- 8 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 src/common/services/ImageDownloader.ts rename src/utils/helpers/{csv.ts => files.ts} (67%) diff --git a/src/common/services/ImageDownloader.ts b/src/common/services/ImageDownloader.ts new file mode 100644 index 00000000..2e131d7a --- /dev/null +++ b/src/common/services/ImageDownloader.ts @@ -0,0 +1,13 @@ +import { AxiosInstance } from 'axios'; +import { saveUrl } from '../../utils/helpers/files'; + +export class ImageDownloader { + public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {} + + public async saveImage(imgUrl: string, filename: string): Promise { + const { data } = await this.axios.get(imgUrl, { responseType: 'blob' }); + const url = URL.createObjectURL(data); + + saveUrl(this.window, url, filename); + } +} diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index eccd43f2..a7a71139 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler'; import ShlinkVersionsContainer from '../ShlinkVersionsContainer'; import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; +import { ImageDownloader } from './ImageDownloader'; const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { + // Services bottle.constant('window', (global as any).window); bottle.constant('console', global.console); bottle.constant('axios', axios); + bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window'); + + // Components bottle.serviceFactory('ScrollToTop', ScrollToTop); bottle.decorator('ScrollToTop', withRouter); diff --git a/src/servers/services/ServersExporter.ts b/src/servers/services/ServersExporter.ts index a54536a4..f230af67 100644 --- a/src/servers/services/ServersExporter.ts +++ b/src/servers/services/ServersExporter.ts @@ -2,7 +2,7 @@ import { dissoc, values } from 'ramda'; import { CsvJson } from 'csvjson'; import LocalStorage from '../../utils/services/LocalStorage'; import { ServersMap } from '../data'; -import { saveCsv } from '../../utils/helpers/csv'; +import { saveCsv } from '../../utils/helpers/files'; const SERVERS_FILENAME = 'shlink-servers.csv'; diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index 3d4d9236..9b43b58c 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -1,5 +1,7 @@ -import { useMemo, useState } from 'react'; -import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap'; +import { FC, useMemo, useState } from 'react'; +import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons'; import { ExternalLink } from 'react-external-link'; import classNames from 'classnames'; import { ShortUrlModalProps } from '../data'; @@ -8,13 +10,17 @@ import { DropdownBtn } from '../../utils/DropdownBtn'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes'; import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features'; +import { ImageDownloader } from '../../common/services/ImageDownloader'; +import { Versions } from '../../utils/helpers/version'; import './QrCodeModal.scss'; interface QrCodeModalConnectProps extends ShortUrlModalProps { selectedServer: SelectedServer; } -const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => { +const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC) => ( + { shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, +) => { const [ size, setSize ] = useState(300); const [ margin, setMargin ] = useState(0); const [ format, setFormat ] = useState('png'); @@ -90,12 +96,21 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
-
QR code URL:
QR code -
{size}x{size}
+ +
+ +
+
diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 49dee640..48541144 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); - bottle.serviceFactory('QrCodeModal', () => QrCodeModal); + bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion'); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); // Services diff --git a/src/utils/helpers/csv.ts b/src/utils/helpers/files.ts similarity index 67% rename from src/utils/helpers/csv.ts rename to src/utils/helpers/files.ts index 08c895a0..89f0da85 100644 --- a/src/utils/helpers/csv.ts +++ b/src/utils/helpers/files.ts @@ -1,7 +1,5 @@ -export const saveCsv = ({ document }: Window, csv: string, filename: string) => { +export const saveUrl = ({ document }: Window, url: string, filename: string) => { const link = document.createElement('a'); - const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', filename); @@ -10,3 +8,10 @@ export const saveCsv = ({ document }: Window, csv: string, filename: string) => link.click(); document.body.removeChild(link); }; + +export const saveCsv = (window: Window, csv: string, filename: string) => { + const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + + saveUrl(window, url, filename); +}; diff --git a/src/visits/services/VisitsExporter.ts b/src/visits/services/VisitsExporter.ts index daa8b81b..ff863e8b 100644 --- a/src/visits/services/VisitsExporter.ts +++ b/src/visits/services/VisitsExporter.ts @@ -1,6 +1,6 @@ import { CsvJson } from 'csvjson'; import { NormalizedVisit } from '../types'; -import { saveCsv } from '../../utils/helpers/csv'; +import { saveCsv } from '../../utils/helpers/files'; export class VisitsExporter { public constructor( diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx index b57b8fcc..9cdf1bb7 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/test/short-urls/helpers/QrCodeModal.test.tsx @@ -1,16 +1,19 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { ExternalLink } from 'react-external-link'; -import { Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; +import { Button, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; import { Mock } from 'ts-mockery'; -import QrCodeModal from '../../../src/short-urls/helpers/QrCodeModal'; +import createQrCodeModal from '../../../src/short-urls/helpers/QrCodeModal'; import { ShortUrl } from '../../../src/short-urls/data'; import { ReachableServer } from '../../../src/servers/data'; import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon'; import { DropdownBtn } from '../../../src/utils/DropdownBtn'; import { SemVer } from '../../../src/utils/helpers/version'; +import { ImageDownloader } from '../../../src/common/services/ImageDownloader'; describe('', () => { let wrapper: ShallowWrapper; + const saveImage = jest.fn(); + const QrCodeModal = createQrCodeModal(Mock.of({ saveImage }), () => null); const shortUrl = 'https://doma.in/abc123'; const createWrapper = (version: SemVer = '2.6.0') => { const selectedServer = Mock.of({ version }); @@ -28,6 +31,7 @@ describe('', () => { }; afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); it('shows an external link to the URL in the header', () => { const wrapper = createWrapper(); @@ -78,7 +82,6 @@ describe('', () => { sizeInput.simulate('change', { target: { value: `${size}` } }); marginInput.simulate('change', { target: { value: `${margin}` } }); - expect(wrapper.find('.mt-2').text()).toEqual(`${size}x${size}`); expect(wrapper.find('label').at(0).text()).toEqual(`Size: ${size}px`); expect(wrapper.find('label').at(1).text()).toEqual(`Margin: ${margin}px`); expect(wrapper.find(Modal).prop('size')).toEqual(modalSize); @@ -96,4 +99,13 @@ describe('', () => { expect(dropdown).toHaveLength(expectedAmountOfDropdowns); expect(firstCol.prop('className')).toEqual(expectedRangeClass); }); + + it('saves the QR code image when clicking the Download button', () => { + const wrapper = createWrapper(); + const downloadBtn = wrapper.find(Button); + + expect(saveImage).not.toHaveBeenCalled(); + downloadBtn.simulate('click'); + expect(saveImage).toHaveBeenCalledTimes(1); + }); }); From aa8f2a0cbcbfaf67722135dcefb895b3a4339351 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 13:15:16 +0200 Subject: [PATCH 10/76] Updated changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd3121f..0c4b3d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far. * `includes`: Suggests tags that contain the input. +* [#464](https://github.com/shlinkio/shlink-web-client/pull/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released. + ### Changed * *Nothing* From 0ecb771b2330b17f906058e2a2e1472d21246f42 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 13:21:53 +0200 Subject: [PATCH 11/76] Created lint:fix global command --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index efe43b57..d2376a65 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "license": "MIT", "scripts": { "lint": "npm run lint:css && npm run lint:js", - "lint:js": "eslint --ext .js,.ts,.tsx src test", - "lint:js:fix": "npm run lint:js -- --fix", "lint:css": "stylelint src/*.scss src/**/*.scss", + "lint:js": "eslint --ext .js,.ts,.tsx src test", + "lint:fix": "npm run lint:css:fix && npm run lint:js:fix", "lint:css:fix": "npm run lint:css -- --fix", + "lint:js:fix": "npm run lint:js -- --fix", "start": "node scripts/start.js", "serve:build": "serve ./build", "build": "node scripts/build.js", From 461c0e0bc9fcc66965dd09a89989d947a7f3d515 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 17:13:31 +0200 Subject: [PATCH 12/76] Added new component for QR codes error correction when consuming Shlink 2.8 --- src/short-urls/helpers/QrCodeModal.tsx | 93 ++++++++++++-------- src/utils/helpers/features.ts | 2 + src/utils/helpers/qrCodes.ts | 9 +- test/short-urls/helpers/QrCodeModal.test.tsx | 6 +- 4 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index 9b43b58c..fd20672a 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -8,8 +8,13 @@ import { ShortUrlModalProps } from '../data'; import { SelectedServer } from '../../servers/data'; import { DropdownBtn } from '../../utils/DropdownBtn'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; -import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes'; -import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features'; +import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; +import { + supportsQrCodeSizeInQuery, + supportsQrCodeSvgFormat, + supportsQrCodeMargin, + supportsQrErrorCorrection, +} from '../../utils/helpers/features'; import { ImageDownloader } from '../../common/services/ImageDownloader'; import { Versions } from '../../utils/helpers/version'; import './QrCodeModal.scss'; @@ -18,20 +23,22 @@ interface QrCodeModalConnectProps extends ShortUrlModalProps { selectedServer: SelectedServer; } -const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC) => ( +const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC) => ( // eslint-disable-line { shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps, ) => { const [ size, setSize ] = useState(300); const [ margin, setMargin ] = useState(0); const [ format, setFormat ] = useState('png'); + const [ errorCorrection, setErrorCorrection ] = useState('L'); const capabilities: QrCodeCapabilities = useMemo(() => ({ useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer), svgIsSupported: supportsQrCodeSvgFormat(selectedServer), marginIsSupported: supportsQrCodeMargin(selectedServer), - }), [ selectedServer ]); + errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer), + }) as QrCodeCapabilities, [ selectedServer ]); const qrCodeUrl = useMemo( - () => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities), - [ shortUrl, size, format, margin, capabilities ], + () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), + [ shortUrl, size, format, margin, errorCorrection, capabilities ], ); const totalSize = useMemo(() => size + margin, [ size, margin ]); const modalSize = useMemo(() => { @@ -48,50 +55,64 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC{shortUrl} - -
+ - - + + setSize(Number(e.target.value))} + /> + + {capabilities.marginIsSupported && ( + + setSize(Number(e.target.value))} + value={margin} + step={1} + min={0} + max={100} + onChange={(e) => setMargin(Number(e.target.value))} /> -
- {capabilities.marginIsSupported && ( -
- - - setMargin(Number(e.target.value))} - /> - -
)} {capabilities.svgIsSupported && ( -
+ setFormat('png')}>PNG setFormat('svg')}>SVG -
+ + )} + {capabilities.errorCorrectionIsSupported && ( + + + setErrorCorrection('L')}> + Low + + setErrorCorrection('M')}> + Medium + + setErrorCorrection('Q')}> + Quartile + + setErrorCorrection('H')}> + High + + + )}
diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 9eb314f2..609c9e95 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -27,3 +27,5 @@ export const supportsTagsInPatch = supportsShortUrlTitle; export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); export const supportsCrawlableVisits = supportsBotVisits; + +export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); diff --git a/src/utils/helpers/qrCodes.ts b/src/utils/helpers/qrCodes.ts index 60342bd4..fb94350a 100644 --- a/src/utils/helpers/qrCodes.ts +++ b/src/utils/helpers/qrCodes.ts @@ -5,26 +5,31 @@ export interface QrCodeCapabilities { useSizeInPath: boolean; svgIsSupported: boolean; marginIsSupported: boolean; + errorCorrectionIsSupported: boolean; } export type QrCodeFormat = 'svg' | 'png'; +export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H'; + export interface QrCodeOptions { size: number; format: QrCodeFormat; margin: number; + errorCorrection: QrErrorCorrection; } export const buildQrCodeUrl = ( shortUrl: string, - { size, format, margin }: QrCodeOptions, - { useSizeInPath, svgIsSupported, marginIsSupported }: QrCodeCapabilities, + { size, format, margin, errorCorrection }: QrCodeOptions, + { useSizeInPath, svgIsSupported, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities, ): string => { const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`; const query = stringifyQuery({ size: useSizeInPath ? undefined : size, format: svgIsSupported ? format : undefined, margin: marginIsSupported && margin > 0 ? margin : undefined, + errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined, }); return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`; diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx index 9cdf1bb7..2daeafce 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/test/short-urls/helpers/QrCodeModal.test.tsx @@ -1,6 +1,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { ExternalLink } from 'react-external-link'; -import { Button, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; +import { Button, FormGroup, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; import { Mock } from 'ts-mockery'; import createQrCodeModal from '../../../src/short-urls/helpers/QrCodeModal'; import { ShortUrl } from '../../../src/short-urls/data'; @@ -48,6 +48,7 @@ describe('', () => { [ '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', (version, margin, expectedUrl) => { const wrapper = createWrapper(version); const formControls = wrapper.find('.form-control-range'); @@ -91,10 +92,11 @@ describe('', () => { [ '2.3.0' as SemVer, 0, 'col-12' ], [ '2.4.0' as SemVer, 1, 'col-md-6' ], [ '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 wrapper = createWrapper(version); const dropdown = wrapper.find(DropdownBtn); - const firstCol = wrapper.find(Row).find('div').first(); + const firstCol = wrapper.find(Row).find(FormGroup).first(); expect(dropdown).toHaveLength(expectedAmountOfDropdowns); expect(firstCol.prop('className')).toEqual(expectedRangeClass); From 520e52595f22c94196ee9129d403c139d9344597 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 17:14:57 +0200 Subject: [PATCH 13/76] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4b3d18..76565154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * `includes`: Suggests tags that contain the input. * [#464](https://github.com/shlinkio/shlink-web-client/pull/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released. +* [#469](https://github.com/shlinkio/shlink-web-client/pull/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher. ### Changed * *Nothing* From 51663407793b9d7ff0c770130d92f7d40ea30eb4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 17:26:54 +0200 Subject: [PATCH 14/76] Extracted some QR code modal components to external components --- src/short-urls/helpers/QrCodeModal.tsx | 25 ++++------------- .../qr-codes/QrErrorCorrectionDropdown.tsx | 28 +++++++++++++++++++ .../helpers/qr-codes/QrFormatDropdown.tsx | 16 +++++++++++ test/short-urls/helpers/QrCodeModal.test.tsx | 7 +++-- 4 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx create mode 100644 src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index fd20672a..ad4fc209 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -1,12 +1,11 @@ import { FC, useMemo, useState } from 'react'; -import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap'; +import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons'; import { ExternalLink } from 'react-external-link'; import classNames from 'classnames'; import { ShortUrlModalProps } from '../data'; import { SelectedServer } from '../../servers/data'; -import { DropdownBtn } from '../../utils/DropdownBtn'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; import { @@ -17,7 +16,9 @@ import { } from '../../utils/helpers/features'; import { ImageDownloader } from '../../common/services/ImageDownloader'; import { Versions } from '../../utils/helpers/version'; +import { QrFormatDropdown } from './qr-codes/QrFormatDropdown'; import './QrCodeModal.scss'; +import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown'; interface QrCodeModalConnectProps extends ShortUrlModalProps { selectedServer: SelectedServer; @@ -90,28 +91,12 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC - - setFormat('png')}>PNG - setFormat('svg')}>SVG - + )} {capabilities.errorCorrectionIsSupported && ( - - setErrorCorrection('L')}> - Low - - setErrorCorrection('M')}> - Medium - - setErrorCorrection('Q')}> - Quartile - - setErrorCorrection('H')}> - High - - + )} diff --git a/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx b/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx new file mode 100644 index 00000000..b6fd68b7 --- /dev/null +++ b/src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; +import { DropdownItem } from 'reactstrap'; +import { DropdownBtn } from '../../../utils/DropdownBtn'; +import { QrErrorCorrection } from '../../../utils/helpers/qrCodes'; + +interface QrErrorCorrectionDropdownProps { + errorCorrection: QrErrorCorrection; + setErrorCorrection: (errorCorrection: QrErrorCorrection) => void; +} + +export const QrErrorCorrectionDropdown: FC = ( + { errorCorrection, setErrorCorrection }, +) => ( + + setErrorCorrection('L')}> + Low + + setErrorCorrection('M')}> + Medium + + setErrorCorrection('Q')}> + Quartile + + setErrorCorrection('H')}> + High + + +); diff --git a/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx b/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx new file mode 100644 index 00000000..3c9ca705 --- /dev/null +++ b/src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import { DropdownItem } from 'reactstrap'; +import { DropdownBtn } from '../../../utils/DropdownBtn'; +import { QrCodeFormat } from '../../../utils/helpers/qrCodes'; + +interface QrFormatDropdownProps { + format: QrCodeFormat; + setFormat: (format: QrCodeFormat) => void; +} + +export const QrFormatDropdown: FC = ({ format, setFormat }) => ( + + setFormat('png')}>PNG + setFormat('svg')}>SVG + +); diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx index 2daeafce..aa8b5160 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/test/short-urls/helpers/QrCodeModal.test.tsx @@ -6,9 +6,10 @@ import createQrCodeModal from '../../../src/short-urls/helpers/QrCodeModal'; import { ShortUrl } from '../../../src/short-urls/data'; import { ReachableServer } from '../../../src/servers/data'; import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon'; -import { DropdownBtn } from '../../../src/utils/DropdownBtn'; import { SemVer } from '../../../src/utils/helpers/version'; import { ImageDownloader } from '../../../src/common/services/ImageDownloader'; +import { QrFormatDropdown } from '../../../src/short-urls/helpers/qr-codes/QrFormatDropdown'; +import { QrErrorCorrectionDropdown } from '../../../src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown'; describe('', () => { let wrapper: ShallowWrapper; @@ -95,10 +96,10 @@ describe('', () => { [ '2.8.0' as SemVer, 2, 'col-md-6' ], ])('shows expected components based on server version', (version, expectedAmountOfDropdowns, expectedRangeClass) => { const wrapper = createWrapper(version); - const dropdown = wrapper.find(DropdownBtn); + const dropdownsLength = wrapper.find(QrFormatDropdown).length + wrapper.find(QrErrorCorrectionDropdown).length; const firstCol = wrapper.find(Row).find(FormGroup).first(); - expect(dropdown).toHaveLength(expectedAmountOfDropdowns); + expect(dropdownsLength).toEqual(expectedAmountOfDropdowns); expect(firstCol.prop('className')).toEqual(expectedRangeClass); }); From c6be8bd96f631a1646c7c6708292a15528f881e0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 17:38:25 +0200 Subject: [PATCH 15/76] Created tests for new QR code dropdowns --- .../QrErrorCorrectionDropdown.test.tsx | 47 +++++++++++++++++++ .../qr-codes/QrFormatDropdown.test.tsx | 37 +++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx create mode 100644 test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx diff --git a/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx b/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx new file mode 100644 index 00000000..32db92f8 --- /dev/null +++ b/test/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown.test.tsx @@ -0,0 +1,47 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { QrErrorCorrection } from '../../../../src/utils/helpers/qrCodes'; +import { QrErrorCorrectionDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown'; + +describe('', () => { + const initialErrorCorrection: QrErrorCorrection = 'Q'; + const setErrorCorrection = jest.fn(); + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow( + , + ); + }); + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it('renders initial state', () => { + const items = wrapper.find(DropdownItem); + + expect(wrapper.prop('text')).toEqual('Error correction (Q)'); + expect(items.at(0).prop('active')).toEqual(false); + expect(items.at(1).prop('active')).toEqual(false); + expect(items.at(2).prop('active')).toEqual(true); + expect(items.at(3).prop('active')).toEqual(false); + }); + + it('invokes callback when items are clicked', () => { + const items = wrapper.find(DropdownItem); + + expect(setErrorCorrection).not.toHaveBeenCalled(); + + items.at(0).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('L'); + + items.at(1).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('M'); + + items.at(2).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('Q'); + + items.at(3).simulate('click'); + expect(setErrorCorrection).toHaveBeenCalledWith('H'); + }); +}); diff --git a/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx b/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx new file mode 100644 index 00000000..c40e05d0 --- /dev/null +++ b/test/short-urls/helpers/qr-codes/QrFormatDropdown.test.tsx @@ -0,0 +1,37 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { QrCodeFormat } from '../../../../src/utils/helpers/qrCodes'; +import { QrFormatDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrFormatDropdown'; + +describe('', () => { + const initialFormat: QrCodeFormat = 'svg'; + const setFormat = jest.fn(); + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it('renders initial state', () => { + const items = wrapper.find(DropdownItem); + + expect(wrapper.prop('text')).toEqual('Format (svg)'); + expect(items.at(0).prop('active')).toEqual(false); + expect(items.at(1).prop('active')).toEqual(true); + }); + + it('invokes callback when items are clicked', () => { + const items = wrapper.find(DropdownItem); + + expect(setFormat).not.toHaveBeenCalled(); + + items.at(0).simulate('click'); + expect(setFormat).toHaveBeenCalledWith('png'); + + items.at(1).simulate('click'); + expect(setFormat).toHaveBeenCalledWith('svg'); + }); +}); From 37a3a2022bb3c744588c64f80305d1f23a0939a3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Aug 2021 17:44:11 +0200 Subject: [PATCH 16/76] Added missing props on qrCodes test --- src/short-urls/helpers/QrCodeModal.tsx | 2 +- test/utils/helpers/qrCodes.test.ts | 48 +++++++++++++++----------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index ad4fc209..6521e5e1 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -36,7 +36,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), [ shortUrl, size, format, margin, errorCorrection, capabilities ], diff --git a/test/utils/helpers/qrCodes.test.ts b/test/utils/helpers/qrCodes.test.ts index 12f1afdd..cf77e34f 100644 --- a/test/utils/helpers/qrCodes.test.ts +++ b/test/utils/helpers/qrCodes.test.ts @@ -1,68 +1,74 @@ -import { buildQrCodeUrl, QrCodeFormat } from '../../../src/utils/helpers/qrCodes'; +import { buildQrCodeUrl, QrCodeFormat, QrErrorCorrection } from '../../../src/utils/helpers/qrCodes'; describe('qrCodes', () => { describe('buildQrCodeUrl', () => { test.each([ [ 'foo.com', - { size: 530, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false }, + { size: 530, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.com/qr-code/530?format=svg', ], [ 'foo.com', - { size: 530, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false }, + { size: 530, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.com/qr-code/530?format=png', ], [ 'bar.io', - { size: 870, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: false, svgIsSupported: false, marginIsSupported: false }, + { size: 870, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: false, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=870', ], [ 'bar.io', - { size: 200, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false }, + { size: 200, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=png', ], [ 'bar.io', - { size: 200, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false }, + { size: 200, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=svg', ], [ 'foo.net', - { size: 480, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false }, + { size: 480, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.net/qr-code/480', ], [ 'foo.net', - { size: 480, format: 'svg' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false }, + { size: 480, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.net/qr-code/480', ], [ 'shlink.io', - { size: 123, format: 'svg' as QrCodeFormat, margin: 10 }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false }, + { size: 123, format: 'svg' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/123', ], [ 'shlink.io', - { size: 456, format: 'png' as QrCodeFormat, margin: 10 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true }, + { size: 456, format: 'png' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/456?format=png&margin=10', ], [ 'shlink.io', - { size: 456, format: 'png' as QrCodeFormat, margin: 0 }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true }, + { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/456?format=png', ], + [ + 'shlink.io', + { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'H' as QrErrorCorrection }, + { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: true }, + 'shlink.io/qr-code/456?format=png&errorCorrection=H', + ], ])('builds expected URL based in params', (shortUrl, options, capabilities, expectedUrl) => { expect(buildQrCodeUrl(shortUrl, options, capabilities)).toEqual(expectedUrl); }); From a28a4846bca597090a02aa350d8d13dffcddab18 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Aug 2021 17:30:07 +0200 Subject: [PATCH 17/76] Created base structure to manage domains --- src/api/types/index.ts | 7 ++ src/common/AsideMenu.tsx | 27 +++++--- src/common/MenuLayout.tsx | 5 +- src/common/services/provideServices.ts | 1 + src/domains/ManageDomains.tsx | 92 +++++++++++++++++++++++++ src/domains/services/provideServices.ts | 4 ++ src/mercure/helpers/Topics.ts | 6 +- src/servers/Overview.tsx | 2 +- src/short-urls/ShortUrlsList.tsx | 2 +- src/tags/TagsList.tsx | 7 +- src/utils/helpers/features.ts | 2 + src/visits/OrphanVisits.tsx | 2 +- src/visits/TagVisits.tsx | 2 +- 13 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 src/domains/ManageDomains.tsx diff --git a/src/api/types/index.ts b/src/api/types/index.ts index acd0d4f7..9d75eaf5 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -65,9 +65,16 @@ export interface ShlinkShortUrlData extends ShortUrlMeta { tags?: string[]; } +interface ShlinkDomainRedirects { + baseUrlRedirect: string, + regular404Redirect: string, + invalidShortUrlRedirect: string +} + export interface ShlinkDomain { domain: string; isDefault: boolean; + redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8 } export interface ShlinkDomainsResponse { diff --git a/src/common/AsideMenu.tsx b/src/common/AsideMenu.tsx index 3d9fd4b8..4f7ec14a 100644 --- a/src/common/AsideMenu.tsx +++ b/src/common/AsideMenu.tsx @@ -4,6 +4,7 @@ import { faTags as tagsIcon, faPen as editIcon, faHome as overviewIcon, + faGlobe as domainsIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FC } from 'react'; @@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom'; import classNames from 'classnames'; import { Location } from 'history'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; -import { ServerWithId } from '../servers/data'; +import { isServerWithId, SelectedServer } from '../servers/data'; +import { supportsDomainRedirects } from '../utils/helpers/features'; import './AsideMenu.scss'; export interface AsideMenuProps { - selectedServer: ServerWithId; + selectedServer: SelectedServer; className?: string; showOnMobile?: boolean; } @@ -38,7 +40,8 @@ const AsideMenuItem: FC = ({ children, to, className, ...res const AsideMenu = (DeleteServerButton: FC) => ( { selectedServer, showOnMobile = false }: AsideMenuProps, ) => { - const serverId = selectedServer ? selectedServer.id : ''; + const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; + const addManageDomainsLink = supportsDomainRedirects(selectedServer); const asideClass = classNames('aside-menu', { 'aside-menu--hidden': !showOnMobile, }); @@ -64,15 +67,23 @@ const AsideMenu = (DeleteServerButton: FC) => ( Manage tags + {addManageDomainsLink && ( + + + Manage domains + + )} Edit this server - + {isServerWithId(selectedServer) && ( + + )} ); diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 669b2197..871a0f97 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 { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features'; +import { supportsDomainRedirects, supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features'; import { isReachableServer } from '../servers/data'; import NotFound from './NotFound'; import { AsideMenuProps } from './AsideMenu'; @@ -22,6 +22,7 @@ const MenuLayout = ( ServerError: FC, Overview: FC, EditShortUrl: FC, + ManageDomains: FC, ) => withSelectedServer(({ location, selectedServer }) => { const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); @@ -33,6 +34,7 @@ const MenuLayout = ( const addTagsVisitsRoute = supportsTagVisits(selectedServer); const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer); + const addManageDomainsRoute = supportsDomainRedirects(selectedServer); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const swipeableProps = useSwipeable(showSidebar, hideSidebar); @@ -55,6 +57,7 @@ const MenuLayout = ( {addTagsVisitsRoute && } {addOrphanVisitsRoute && } + {addManageDomainsRoute && } List short URLs} /> diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index a7a71139..2abbe688 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -43,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: 'ServerError', 'Overview', 'EditShortUrl', + 'ManageDomains', ); bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', withRouter); diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx new file mode 100644 index 00000000..5d392cc5 --- /dev/null +++ b/src/domains/ManageDomains.tsx @@ -0,0 +1,92 @@ +import { FC, useEffect } from 'react'; +import { faCheck as defaultDomainIcon, faEdit as editIcon, faBan as forbiddenIcon } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, UncontrolledTooltip } from 'reactstrap'; +import Message from '../utils/Message'; +import { Result } from '../utils/Result'; +import { ShlinkApiError } from '../api/ShlinkApiError'; +import { SimpleCard } from '../utils/SimpleCard'; +import { DomainsList } from './reducers/domainsList'; +import SearchField from '../utils/SearchField'; + +interface ManageDomainsProps { + listDomains: Function; + domainsList: DomainsList; +} + +const Na: FC = () => N/A; +const DefaultDomain: FC = () => ( + <> + + Default domain + +); + +export const ManageDomains: FC = ({ listDomains, domainsList }) => { + const { domains, loading, error } = domainsList; + + useEffect(() => { + listDomains(); + }, []); + + const renderContent = () => { + if (loading) { + return ; + } + + if (error) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + {domains.map((domain) => ( + + + + + + + + + ))} + +
+ DomainBase path redirectRegular 404 redirectInvalid short URL redirect +
{domain.isDefault ? : ''}{domain.domain}{domain.redirects?.baseUrlRedirect ?? }{domain.redirects?.regular404Redirect ?? }{domain.redirects?.invalidShortUrlRedirect ?? } + + + + {domain.isDefault && ( + + Redirects for default domain cannot be edited here. + + )} +
+
+ ); + }; + + return ( + <> + {}} /> + {renderContent()} + + ); +}; diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index bd56d8a2..bf90cc7a 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -2,12 +2,16 @@ import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; import { listDomains } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; +import { ManageDomains } from '../ManageDomains'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('DomainSelector', () => DomainSelector); bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); + bottle.serviceFactory('ManageDomains', () => ManageDomains); + bottle.decorator('ManageDomains', connect([ 'domainsList' ], [ 'listDomains' ])); + // Actions bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); }; diff --git a/src/mercure/helpers/Topics.ts b/src/mercure/helpers/Topics.ts index 42e08d4f..663cc371 100644 --- a/src/mercure/helpers/Topics.ts +++ b/src/mercure/helpers/Topics.ts @@ -1,7 +1,7 @@ export class Topics { - public static visits = () => 'https://shlink.io/new-visit'; + public static readonly visits = 'https://shlink.io/new-visit'; - public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`; + public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit'; - public static orphanVisits = () => 'https://shlink.io/new-orphan-visit'; + public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`; } diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index ea156041..84614b2e 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -120,4 +120,4 @@ export const Overview = ( ); -}, () => [ Topics.visits(), Topics.orphanVisits() ]); +}, () => [ Topics.visits, Topics.orphanVisits ]); diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 4736bc8c..1cac40b5 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default ShortUrlsList; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index fde6cc5e..a29345b0 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -1,5 +1,6 @@ import { FC, useEffect, useState } from 'react'; import { splitEvery } from 'ramda'; +import { Row } from 'reactstrap'; import Message from '../utils/Message'; import SearchField from '../utils/SearchField'; import { SelectedServer } from '../servers/data'; @@ -51,7 +52,7 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags); return ( -
+ {tagsGroups.map((group, index) => (
{group.map((tag) => ( @@ -66,7 +67,7 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( ))}
))} -
+ ); }; @@ -76,6 +77,6 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( {renderContent()} ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default TagsList; diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 609c9e95..68651fcc 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -29,3 +29,5 @@ export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); export const supportsCrawlableVisits = supportsBotVisits; export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); + +export const supportsDomainRedirects = supportsQrErrorCorrection; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index 8184e687..e87a93fe 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -41,4 +41,4 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure ); -}, () => [ Topics.orphanVisits() ]); +}, () => [ Topics.orphanVisits ]); diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index 4a80519f..d7619a0a 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -43,6 +43,6 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default TagVisits; From bf29158a8a3ec1b1b16aa15b4a2530ad7e9ea3c5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Aug 2021 17:31:42 +0200 Subject: [PATCH 18/76] Added missing alignment --- src/domains/ManageDomains.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 5d392cc5..82471d5f 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -6,8 +6,8 @@ import Message from '../utils/Message'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; -import { DomainsList } from './reducers/domainsList'; import SearchField from '../utils/SearchField'; +import { DomainsList } from './reducers/domainsList'; interface ManageDomainsProps { listDomains: Function; @@ -63,7 +63,7 @@ export const ManageDomains: FC = ({ listDomains, domainsList {domain.redirects?.baseUrlRedirect ?? } {domain.redirects?.regular404Redirect ?? } {domain.redirects?.invalidShortUrlRedirect ?? } - + + + {domain.isDefault && ( + + Redirects for default domain cannot be edited here. +
+ Use config options or env vars. +
+ )} + + + + ); +}; diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 82471d5f..9d94002b 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -1,43 +1,41 @@ import { FC, useEffect } from 'react'; -import { faCheck as defaultDomainIcon, faEdit as editIcon, faBan as forbiddenIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, UncontrolledTooltip } from 'reactstrap'; import Message from '../utils/Message'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; import SearchField from '../utils/SearchField'; +import { ShlinkDomainRedirects } from '../api/types'; import { DomainsList } from './reducers/domainsList'; +import { DomainRow } from './DomainRow'; interface ManageDomainsProps { listDomains: Function; + filterDomains: (searchTerm: string) => void; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; domainsList: DomainsList; } -const Na: FC = () => N/A; -const DefaultDomain: FC = () => ( - <> - - Default domain - -); +const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; -export const ManageDomains: FC = ({ listDomains, domainsList }) => { - const { domains, loading, error } = domainsList; +export const ManageDomains: FC = ( + { listDomains, domainsList, filterDomains, editDomainRedirects }, +) => { + const { filteredDomains: domains, loading, error, errorData } = domainsList; + const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects; useEffect(() => { listDomains(); }, []); - const renderContent = () => { - if (loading) { - return ; - } + if (loading) { + return ; + } + const renderContent = () => { if (error) { return ( - + ); } @@ -46,36 +44,17 @@ export const ManageDomains: FC = ({ listDomains, domainsList - - - - - - + {headers.map((column, index) => )} + {domains.length < 1 && } {domains.map((domain) => ( - - - - - - - - + ))}
- DomainBase path redirectRegular 404 redirectInvalid short URL redirect -
{column}
No results found
{domain.isDefault ? : ''}{domain.domain}{domain.redirects?.baseUrlRedirect ?? }{domain.redirects?.regular404Redirect ?? }{domain.redirects?.invalidShortUrlRedirect ?? } - - - - {domain.isDefault && ( - - Redirects for default domain cannot be edited here. - - )} -
@@ -85,7 +64,7 @@ export const ManageDomains: FC = ({ listDomains, domainsList return ( <> - {}} /> + {renderContent()} ); diff --git a/src/domains/helpers/EditDomainRedirectsModal.tsx b/src/domains/helpers/EditDomainRedirectsModal.tsx new file mode 100644 index 00000000..a74b9c4a --- /dev/null +++ b/src/domains/helpers/EditDomainRedirectsModal.tsx @@ -0,0 +1,85 @@ +import { FC, useState } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader, UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { FormGroupContainer } from '../../utils/FormGroupContainer'; +import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils'; + +interface EditDomainRedirectsModalProps { + domain: ShlinkDomain; + isOpen: boolean; + toggle: () => void; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; +} + +const InfoTooltip: FC<{ id: string }> = ({ id, children }) => ( + <> + + {children} + +); + +const FormGroup: FC<{ value: string; onChange: (newValue: string) => void; isLast?: boolean }> = ( + { value, onChange, isLast, children }, +) => ( + + {children} + +); + +export const EditDomainRedirectsModal: FC = ( + { isOpen, toggle, domain, editDomainRedirects }, +) => { + const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(''); + const [ regular404Redirect, setRegular404Redirect ] = useState(''); + const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(''); + const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, { + baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect), + regular404Redirect: nonEmptyValueOrNull(regular404Redirect), + invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect), + }).then(toggle)); + + return ( + +
+ + Edit redirects for {domain.domain} + + + + + Visitors accessing the base url, as in https://{domain.domain}/, will be redirected to this URL. + + Base URL + + + + Visitors accessing a url not matching a short URL pattern, as in https://{domain.domain}/???/[...], + will be redirected to this URL. + + Regular 404 + + + + Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be + redirected to this URL. + + Invalid short URL + + + + + + +
+
+ ); +}; diff --git a/src/domains/reducers/domainRedirects.ts b/src/domains/reducers/domainRedirects.ts new file mode 100644 index 00000000..5b350a13 --- /dev/null +++ b/src/domains/reducers/domainRedirects.ts @@ -0,0 +1,33 @@ +import { Action, Dispatch } from 'redux'; +import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import { ShlinkDomainRedirects } from '../../api/types'; +import { GetState } from '../../container/types'; +import { ApiErrorAction } from '../../api/types/actions'; +import { parseApiError } from '../../api/utils'; + +/* eslint-disable padding-line-between-statements */ +export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START'; +export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR'; +export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; +/* eslint-enable padding-line-between-statements */ + +export interface EditDomainRedirectsAction extends Action { + domain: string; + redirects: ShlinkDomainRedirects; +} + +export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + domain: string, + domainRedirects: Partial, +) => async (dispatch: Dispatch, getState: GetState) => { + dispatch({ type: EDIT_DOMAIN_REDIRECTS_START }); + const { editDomainRedirects } = buildShlinkApiClient(getState); + + try { + const redirects = await editDomainRedirects({ domain, ...domainRedirects }); + + dispatch({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); + } catch (e) { + dispatch({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) }); + } +}; diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 751e2f4f..6a5c80b0 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,35 +1,63 @@ import { Action, Dispatch } from 'redux'; -import { ShlinkDomain } from '../../api/types'; +import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; +import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; +import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; /* eslint-disable padding-line-between-statements */ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; +export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; /* eslint-enable padding-line-between-statements */ export interface DomainsList { domains: ShlinkDomain[]; + filteredDomains: ShlinkDomain[]; loading: boolean; error: boolean; + errorData?: ProblemDetailsError; } export interface ListDomainsAction extends Action { domains: ShlinkDomain[]; } +interface FilterDomainsAction extends Action { + searchTerm: string; +} + const initialState: DomainsList = { domains: [], + filteredDomains: [], loading: false, error: false, }; -export default buildReducer({ +type DomainsCombinedAction = ListDomainsAction +& ApiErrorAction +& FilterDomainsAction +& EditDomainRedirectsAction; + +const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => + (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; + +export default buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), - [LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }), - [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }), + [LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }), + [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }), + [FILTER_DOMAINS]: (state, { searchTerm }) => ({ + ...state, + filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)), + }), + [EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({ + ...state, + domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), + filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), + }), }, initialState); export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( @@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () dispatch({ type: LIST_DOMAINS, domains }); } catch (e) { - dispatch({ type: LIST_DOMAINS_ERROR }); + dispatch({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); } }; + +export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index bf90cc7a..e6f01b1b 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -1,8 +1,9 @@ import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; -import { listDomains } from '../reducers/domainsList'; +import { filterDomains, listDomains } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; import { ManageDomains } from '../ManageDomains'; +import { editDomainRedirects } from '../reducers/domainRedirects'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components @@ -10,10 +11,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); bottle.serviceFactory('ManageDomains', () => ManageDomains); - bottle.decorator('ManageDomains', connect([ 'domainsList' ], [ 'listDomains' ])); + bottle.decorator('ManageDomains', connect( + [ 'domainsList' ], + [ 'listDomains', 'filterDomains', 'editDomainRedirects' ], + )); // Actions bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); + bottle.serviceFactory('filterDomains', () => filterDomains); + bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); }; export default provideServices; diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index de135dd4..97225485 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/SearchBar.tsx @@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl return (
- listShortUrls({ ...shortUrlsListParams, searchTerm }) - } - /> + listShortUrls({ ...shortUrlsListParams, searchTerm })} />
diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index f1f6d900..12c3ae46 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -5,6 +5,7 @@ import { buildReducer, buildActionCreator } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; @@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action { result: ShortUrl; } -export interface CreateShortUrlFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlCreation = { result: null, saving: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), [CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), [CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }), @@ -53,7 +50,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => dispatch({ type: CREATE_SHORT_URL, result }); } catch (e) { - dispatch({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index 23561c18..2530c153 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types'; import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; @@ -24,17 +25,13 @@ export interface DeleteShortUrlAction extends Action { domain?: string | null; } -interface DeleteShortUrlErrorAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlDeletion = { shortCode: '', loading: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }), [DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }), [SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }), @@ -52,7 +49,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => await deleteShortUrl(shortCode, domain); dispatch({ type: SHORT_URL_DELETED, shortCode, domain }); } catch (e) { - dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/src/short-urls/reducers/shortUrlDetail.ts index 1b174f1d..f338e2c7 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/src/short-urls/reducers/shortUrlDetail.ts @@ -7,6 +7,7 @@ import { GetState } from '../../container/types'; import { shortUrlMatches } from '../helpers'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; @@ -25,16 +26,12 @@ export interface ShortUrlDetailAction extends Action { shortUrl: ShortUrl; } -export interface ShortUrlDetailFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlDetail = { loading: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }), [GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }), [GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }), @@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); } catch (e) { - dispatch({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); } }; diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 50537fb1..8777dd47 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; import { supportsTagsInPatch } from '../../utils/helpers/features'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; @@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action { shortUrl: ShortUrl; } -export interface ShortUrlEditionFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlEdition = { saving: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), [EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), [SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }), @@ -59,7 +56,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( dispatch({ shortUrl, type: SHORT_URL_EDITED }); } catch (e) { - dispatch({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index a29345b0..7b05d4bd 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -30,11 +30,11 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( forceListTags(); }, []); - const renderContent = () => { - if (tagsList.loading) { - return ; - } + if (tagsList.loading) { + return ; + } + const renderContent = () => { if (tagsList.error) { return ( @@ -73,7 +73,7 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( return ( <> - {!tagsList.loading && } + {renderContent()} ); diff --git a/src/tags/reducers/tagDelete.ts b/src/tags/reducers/tagDelete.ts index acdac7c2..8a3664dd 100644 --- a/src/tags/reducers/tagDelete.ts +++ b/src/tags/reducers/tagDelete.ts @@ -4,6 +4,7 @@ import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START'; @@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action { tag: string; } -export interface DeleteTagFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: TagDeletion = { deleting: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [DELETE_TAG_START]: () => ({ deleting: true, error: false }), [DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }), [DELETE_TAG]: () => ({ deleting: false, error: false }), @@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: await deleteTags([ tag ]); dispatch({ type: DELETE_TAG }); } catch (e) { - dispatch({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); + dispatch({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/tags/reducers/tagEdit.ts b/src/tags/reducers/tagEdit.ts index 3bc85c88..d28f0838 100644 --- a/src/tags/reducers/tagEdit.ts +++ b/src/tags/reducers/tagEdit.ts @@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START'; @@ -29,10 +30,6 @@ export interface EditTagAction extends Action { color: string; } -export interface EditTagFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: TagEdition = { oldName: '', newName: '', @@ -40,7 +37,7 @@ const initialState: TagEdition = { error: false, }; -export default buildReducer({ +export default buildReducer({ [EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }), [EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }), [EDIT_TAG]: (_, action) => ({ @@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener colorGenerator.setColorForKey(newName, color); dispatch({ type: EDIT_TAG, oldName, newName }); } catch (e) { - dispatch({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) }); + dispatch({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index a58424cb..90bef1b1 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { CreateVisit, Stats } from '../../visits/types'; import { parseApiError } from '../../api/utils'; import { TagStats } from '../data'; +import { ApiErrorAction } from '../../api/types/actions'; import { DeleteTagAction, TAG_DELETED } from './tagDelete'; import { EditTagAction, TAG_EDITED } from './tagEdit'; @@ -34,20 +35,16 @@ interface ListTagsAction extends Action { stats: TagsStatsMap; } -interface ListTagsFailedAction extends Action { - errorData?: ProblemDetailsError; -} - interface FilterTagsAction extends Action { searchTerm: string; } -type ListTagsCombinedAction = ListTagsAction +type TagsCombinedAction = ListTagsAction & DeleteTagAction & CreateVisitsAction & EditTagAction & FilterTagsAction -& ListTagsFailedAction; +& ApiErrorAction; const initialState = { tags: [], @@ -83,7 +80,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O }, {}), ); -export default buildReducer({ +export default buildReducer({ [LIST_TAGS_START]: () => ({ ...initialState, loading: true }), [LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }), @@ -130,7 +127,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t dispatch({ tags, stats: processedStats, type: LIST_TAGS }); } catch (e) { - dispatch({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); + dispatch({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); } }; diff --git a/src/utils/FormGroupContainer.tsx b/src/utils/FormGroupContainer.tsx index 34913286..ce66b4c2 100644 --- a/src/utils/FormGroupContainer.tsx +++ b/src/utils/FormGroupContainer.tsx @@ -8,12 +8,14 @@ interface FormGroupContainerProps { id?: string; type?: InputType; required?: boolean; + placeholder?: string; + className?: string; } export const FormGroupContainer: FC = ( - { children, value, onChange, id = uuid(), type = 'text', required = true }, + { children, value, onChange, id = uuid(), type = 'text', required = true, placeholder, className = '' }, ) => ( -
+
@@ -23,6 +25,7 @@ export const FormGroupContainer: FC = ( id={id} value={value} required={required} + placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
diff --git a/src/utils/SearchField.tsx b/src/utils/SearchField.tsx index e571157d..c373ffc4 100644 --- a/src/utils/SearchField.tsx +++ b/src/utils/SearchField.tsx @@ -10,14 +10,11 @@ let timer: NodeJS.Timeout | null; interface SearchFieldProps { onChange: (value: string) => void; className?: string; - placeholder?: string; large?: boolean; noBorder?: boolean; } -const SearchField = ( - { onChange, className, placeholder = 'Search...', large = true, noBorder = false }: SearchFieldProps, -) => { +const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => { const [ searchTerm, setSearchTerm ] = useState(''); const resetTimer = () => { @@ -43,7 +40,7 @@ const SearchField = ( 'form-control-lg': large, 'search-field__input--no-border': noBorder, })} - placeholder={placeholder} + placeholder="Search..." value={searchTerm} onChange={(e) => searchTermChanged(e.target.value)} /> diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 850fd4b6..56a044ed 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -43,3 +43,5 @@ export type OptionalString = Optional; export type RecursivePartial = { [P in keyof T]?: RecursivePartial; }; + +export const nonEmptyValueOrNull = (value: T): T | null => isEmpty(value) ? null : value; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 666e4419..1fd42d02 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,8 +1,9 @@ import { flatten, prop, range, splitEvery } from 'ramda'; import { Action, Dispatch } from 'redux'; import { ShlinkPaginator, ShlinkVisits } from '../../api/types'; -import { Visit, VisitsLoadFailedAction } from '../types'; +import { Visit } from '../types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -72,6 +73,6 @@ export const getVisitsWithLoader = async & { visits: V dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); } catch (e) { - dispatch({ type: actionMap.error, errorData: parseApiError(e) }); + dispatch({ type: actionMap.error, errorData: parseApiError(e) }); } }; diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index ac77a5c5..08d5320d 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,17 +1,11 @@ import { Action, Dispatch } from 'redux'; -import { - OrphanVisit, - OrphanVisitType, - Visit, - VisitsInfo, - VisitsLoadFailedAction, - VisitsLoadProgressChangedAction, -} from '../types'; +import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; import { isOrphanVisit } from '../types/helpers'; +import { ApiErrorAction } from '../../api/types/actions'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -31,7 +25,7 @@ export interface OrphanVisitsAction extends Action { type OrphanVisitsCombinedAction = OrphanVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction -& VisitsLoadFailedAction; +& ApiErrorAction; const initialState: VisitsInfo = { visits: [], diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 2018a66a..688b0812 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,11 +1,12 @@ import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types'; +import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { ShortUrlIdentifier } from '../../short-urls/data'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -27,7 +28,7 @@ interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction -& VisitsLoadFailedAction; +& ApiErrorAction; const initialState: ShortUrlVisits = { visits: [], diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 77cf31b3..cc8140ae 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,9 +1,10 @@ import { Action, Dispatch } from 'redux'; -import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types'; +import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -28,7 +29,7 @@ export interface TagVisitsAction extends Action { type TagsVisitsCombinedAction = TagVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction -& VisitsLoadFailedAction; +& ApiErrorAction; const initialState: TagVisits = { visits: [], diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 60c64a14..2a4de853 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -17,10 +17,6 @@ export interface VisitsLoadProgressChangedAction extends Action { progress: number; } -export interface VisitsLoadFailedAction extends Action { - errorData?: ProblemDetailsError; -} - export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 585bb1da..5bf9f4a3 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -13,20 +13,26 @@ describe('domainsList', () => { const domains = [ Mock.all(), Mock.all(), Mock.all() ]; describe('reducer', () => { - const action = (type: string, args: Partial = {}) => Mock.of( + const action = (type: string, args: Partial = {}): any => Mock.of( { type, ...args }, ); it('returns loading on LIST_DOMAINS_START', () => { - expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual({ domains: [], loading: true, error: false }); + expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual( + { domains: [], filteredDomains: [], loading: true, error: false }, + ); }); it('returns error on LIST_DOMAINS_ERROR', () => { - expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual({ domains: [], loading: false, error: true }); + expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual( + { domains: [], filteredDomains: [], loading: false, error: true }, + ); }); it('returns domains on LIST_DOMAINS', () => { - expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual({ domains, loading: false, error: false }); + expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual( + { domains, filteredDomains: domains, loading: false, error: false }, + ); }); }); diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index d276d933..8bf04278 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { determineOrderDir, rangeOf } from '../../src/utils/utils'; +import { determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; describe('utils', () => { describe('determineOrderDir', () => { @@ -47,4 +47,17 @@ describe('utils', () => { ]); }); }); + + describe('nonEmptyValueOrNull', () => { + it.each([ + [ '', null ], + [ 'Hello', 'Hello' ], + [[], null ], + [[ 1, 2, 3 ], [ 1, 2, 3 ]], + [{}, null ], + [{ foo: 'bar' }, { foo: 'bar' }], + ])('returns expected value based on input', (value, expected) => { + expect(nonEmptyValueOrNull(value)).toEqual(expected); + }); + }); }); From 8e71b2e2b156710235091035095c68b672253ebb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:00:58 +0200 Subject: [PATCH 20/76] Improved domainsList reducer test --- src/domains/DomainRow.tsx | 22 +++++----- src/domains/reducers/domainsList.ts | 4 +- test/domains/reducers/domainsList.test.ts | 50 +++++++++++++++++++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index 44c1de39..de7c3217 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -32,24 +32,26 @@ const DefaultDomain: FC = () => ( export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects }) => { const [ isOpen, toggle ] = useToggle(); + const { domain: authority, isDefault, redirects } = domain; + const domainId = `domainEdit${authority.replace('.', '')}`; return ( - {domain.isDefault ? : ''} - {domain.domain} - {domain.redirects?.baseUrlRedirect ?? } - {domain.redirects?.regular404Redirect ?? } + {isDefault ? : ''} + {authority} + {redirects?.baseUrlRedirect ?? } + {redirects?.regular404Redirect ?? } - {domain.redirects?.invalidShortUrlRedirect ?? } + {redirects?.invalidShortUrlRedirect ?? } - - - {domain.isDefault && ( - + {isDefault && ( + Redirects for default domain cannot be edited here.
Use config options or env vars. diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 6a5c80b0..910c2db5 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -37,12 +37,12 @@ const initialState: DomainsList = { error: false, }; -type DomainsCombinedAction = ListDomainsAction +export type DomainsCombinedAction = ListDomainsAction & ApiErrorAction & FilterDomainsAction & EditDomainRedirectsAction; -const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => +export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; export default buildReducer({ diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 5bf9f4a3..5bc1eb9c 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -3,17 +3,23 @@ import reducer, { LIST_DOMAINS, LIST_DOMAINS_ERROR, LIST_DOMAINS_START, - ListDomainsAction, + FILTER_DOMAINS, + DomainsCombinedAction, + DomainsList, listDomains as listDomainsAction, + filterDomains as filterDomainsAction, + replaceRedirectsOnDomain, } from '../../../src/domains/reducers/domainsList'; -import { ShlinkDomain } from '../../../src/api/types'; +import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; describe('domainsList', () => { - const domains = [ Mock.all(), Mock.all(), Mock.all() ]; + const filteredDomains = [ Mock.of({ domain: 'foo' }), Mock.of({ domain: 'boo' }) ]; + const domains = [ ...filteredDomains, Mock.of({ domain: 'bar' }) ]; describe('reducer', () => { - const action = (type: string, args: Partial = {}): any => Mock.of( + const action = (type: string, args: Partial = {}) => Mock.of( { type, ...args }, ); @@ -34,6 +40,32 @@ describe('domainsList', () => { { domains, filteredDomains: domains, loading: false, error: false }, ); }); + + it('filters domains on FILTER_DOMAINS', () => { + expect(reducer(Mock.of({ domains }), action(FILTER_DOMAINS, { searchTerm: 'oo' }))).toEqual( + { domains, filteredDomains }, + ); + }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'does_not_exist' ], + ])('replaces redirects on proper domain on EDIT_DOMAIN_REDIRECTS', (domain) => { + const redirects: ShlinkDomainRedirects = { + baseUrlRedirect: 'bar', + regular404Redirect: 'foo', + invalidShortUrlRedirect: null, + }; + + expect(reducer( + Mock.of({ domains, filteredDomains }), + action(EDIT_DOMAIN_REDIRECTS, { domain, redirects }), + )).toEqual({ + domains: domains.map(replaceRedirectsOnDomain(domain, redirects)), + filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), + }); + }); }); describe('listDomains', () => { @@ -66,4 +98,14 @@ describe('domainsList', () => { expect(listDomains).toHaveBeenCalledTimes(1); }); }); + + describe('filterDomains', () => { + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'something' ], + ])('creates action as expected', (searchTerm) => { + expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm }); + }); + }); }); From dce1cefd496702cfc48aec186d097573282fcc38 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:06:18 +0200 Subject: [PATCH 21/76] Created domainRedirects reducer test --- test/domains/reducers/domainRedirects.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/domains/reducers/domainRedirects.test.ts diff --git a/test/domains/reducers/domainRedirects.test.ts b/test/domains/reducers/domainRedirects.test.ts new file mode 100644 index 00000000..3cb56393 --- /dev/null +++ b/test/domains/reducers/domainRedirects.test.ts @@ -0,0 +1,44 @@ +import { Mock } from 'ts-mockery'; +import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; +import { + EDIT_DOMAIN_REDIRECTS, + EDIT_DOMAIN_REDIRECTS_ERROR, + EDIT_DOMAIN_REDIRECTS_START, + editDomainRedirects as editDomainRedirectsAction, +} from '../../../src/domains/reducers/domainRedirects'; +import { ShlinkDomainRedirects } from '../../../src/api/types'; + +describe('domainRedirectsReducer', () => { + beforeEach(jest.clearAllMocks); + + describe('editDomainRedirects', () => { + const domain = 'example.com'; + const redirects = Mock.all(); + const dispatch = jest.fn(); + const getState = jest.fn(); + const editDomainRedirects = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ editDomainRedirects }); + + it('dispatches error when loading domains fails', async () => { + editDomainRedirects.mockRejectedValue(new Error('error')); + + await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS_ERROR }); + expect(editDomainRedirects).toHaveBeenCalledTimes(1); + }); + + it('dispatches domain and redirects once loaded', async () => { + editDomainRedirects.mockResolvedValue(redirects); + + await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); + expect(editDomainRedirects).toHaveBeenCalledTimes(1); + }); + }); +}); From d88f8221259714e0edea3c040701ed251fe7867d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:11:14 +0200 Subject: [PATCH 22/76] Extended ShlinkApiClient test covering editDomainRedirects --- test/api/services/ShlinkApiClient.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index a4a12d8f..95bf899f 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -297,4 +297,17 @@ describe('ShlinkApiClient', () => { expect(result).toEqual(expectedData); }); }); + + describe('editDomainRedirects', () => { + it('returns the redirects', async () => { + const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' }; + const axiosSpy = createAxiosMock({ data: resp }); + const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', ''); + + const result = await editDomainRedirects({ domain: 'foo' }); + + expect(axiosSpy).toHaveBeenCalled(); + expect(result).toEqual(resp); + }); + }); }); From f49b74229c3493fc26fa89adb31ba702e20d1176 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:34:56 +0200 Subject: [PATCH 23/76] Enhanced tooltip --- src/domains/DomainRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index de7c3217..24c3cab7 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -54,7 +54,7 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def Redirects for default domain cannot be edited here.
- Use config options or env vars. + Use config options or env vars directly on the server.
)} From b1d6f5861934c8500711a8f5350c172bd4970efb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 10:46:47 +0200 Subject: [PATCH 24/76] Added responsiveness to manage domains table --- src/domains/DomainRow.tsx | 18 ++++++---- src/domains/ManageDomains.tsx | 2 +- src/index.scss | 1 + src/short-urls/ShortUrlsTable.scss | 8 ----- src/short-urls/ShortUrlsTable.tsx | 2 +- src/short-urls/helpers/ShortUrlsRow.scss | 31 ----------------- src/short-urls/helpers/ShortUrlsRow.tsx | 16 ++++----- src/utils/table/ResponsiveTable.scss | 42 ++++++++++++++++++++++++ 8 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 src/utils/table/ResponsiveTable.scss diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index de7c3217..bf887f52 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -36,15 +36,19 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def const domainId = `domainEdit${authority.replace('.', '')}`; return ( - - {isDefault ? : ''} - {authority} - {redirects?.baseUrlRedirect ?? } - {redirects?.regular404Redirect ?? } - + + {isDefault ? : ''} + {authority} + + {redirects?.baseUrlRedirect ?? } + + + {redirects?.regular404Redirect ?? } + + {redirects?.invalidShortUrlRedirect ?? } - +
- {showExtraValidationsCard && ( - - {showValidateUrl && ( - setShortUrlData({ ...shortUrlData, validateUrl })} + + setShortUrlData({ ...shortUrlData, validateUrl })} + > + Validate URL + + {showCrawlableControl && ( + setShortUrlData({ ...shortUrlData, crawlable })} + > + Make it crawlable + + )} + {!isEdit && ( +

+ setShortUrlData({ ...shortUrlData, findIfExists })} > - Validate URL - - )} - {showCrawlableControl && ( - setShortUrlData({ ...shortUrlData, crawlable })} - > - Make it crawlable - - )} - {!isEdit && ( -

- setShortUrlData({ ...shortUrlData, findIfExists })} - > - Use existing URL if found - - -

- )} -
- )} + Use existing URL if found + + +

+ )} +
)} diff --git a/src/short-urls/helpers/QrCodeModal.tsx b/src/short-urls/helpers/QrCodeModal.tsx index 6521e5e1..053c5e11 100644 --- a/src/short-urls/helpers/QrCodeModal.tsx +++ b/src/short-urls/helpers/QrCodeModal.tsx @@ -10,7 +10,6 @@ import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes'; import { supportsQrCodeSizeInQuery, - supportsQrCodeSvgFormat, supportsQrCodeMargin, supportsQrErrorCorrection, } from '../../utils/helpers/features'; @@ -33,10 +32,10 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC('L'); const capabilities: QrCodeCapabilities = useMemo(() => ({ useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer), - svgIsSupported: supportsQrCodeSvgFormat(selectedServer), marginIsSupported: supportsQrCodeMargin(selectedServer), errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer), }), [ selectedServer ]); + const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported; const qrCodeUrl = useMemo( () => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities), [ shortUrl, size, format, margin, errorCorrection, capabilities ], @@ -58,11 +57,7 @@ const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC {capabilities.marginIsSupported && ( - + )} - {capabilities.svgIsSupported && ( - - - - )} + + + {capabilities.errorCorrectionIsSupported && ( diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 68651fcc..120926f8 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -4,16 +4,6 @@ import { versionMatch, Versions } from './version'; const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedServer): boolean => isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions); -export const supportsSettingShortCodeLength = serverMatchesVersions({ minVersion: '2.1.0' }); - -export const supportsTagVisits = serverMatchesVersions({ minVersion: '2.2.0' }); - -export const supportsListingDomains = serverMatchesVersions({ minVersion: '2.4.0' }); - -export const supportsQrCodeSvgFormat = supportsListingDomains; - -export const supportsValidateUrl = supportsListingDomains; - export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' }); export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' }); diff --git a/src/utils/helpers/qrCodes.ts b/src/utils/helpers/qrCodes.ts index fb94350a..c096f148 100644 --- a/src/utils/helpers/qrCodes.ts +++ b/src/utils/helpers/qrCodes.ts @@ -3,7 +3,6 @@ import { stringifyQuery } from './query'; export interface QrCodeCapabilities { useSizeInPath: boolean; - svgIsSupported: boolean; marginIsSupported: boolean; errorCorrectionIsSupported: boolean; } @@ -22,12 +21,12 @@ export interface QrCodeOptions { export const buildQrCodeUrl = ( shortUrl: string, { size, format, margin, errorCorrection }: QrCodeOptions, - { useSizeInPath, svgIsSupported, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities, + { useSizeInPath, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities, ): string => { const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`; const query = stringifyQuery({ size: useSizeInPath ? undefined : size, - format: svgIsSupported ? format : undefined, + format, margin: marginIsSupported && margin > 0 ? margin : undefined, errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined, }); diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx index 439bb81f..e45dfb3d 100644 --- a/test/common/MenuLayout.test.tsx +++ b/test/common/MenuLayout.test.tsx @@ -49,8 +49,6 @@ describe('', () => { }); it.each([ - [ '2.1.0' as SemVer, 7 ], - [ '2.2.0' as SemVer, 8 ], [ '2.5.0' as SemVer, 8 ], [ '2.6.0' as SemVer, 9 ], [ '2.7.0' as SemVer, 9 ], diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx index 380d1309..66ae1fc1 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -13,9 +13,10 @@ import { parseDate } from '../../src/utils/helpers/date'; describe('', () => { let wrapper: ShallowWrapper; const TagsSelector = () => null; + const DomainSelector = () => null; const createShortUrl = jest.fn(async () => Promise.resolve()); const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => { - const ShortUrlForm = createShortUrlForm(TagsSelector, () => null); + const ShortUrlForm = createShortUrlForm(TagsSelector, DomainSelector); wrapper = shallow( ', () => { wrapper.find(Input).first().simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } }); wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]); wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } }); - wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } }); + wrapper.find(DomainSelector).simulate('change', 'example.com'); wrapper.find('#maxVisits').simulate('change', { target: { value: '20' } }); wrapper.find('#shortCodeLength').simulate('change', { target: { value: 15 } }); wrapper.find(DateInput).at(0).simulate('change', validSince); @@ -68,12 +69,8 @@ describe('', () => { [ null, 'create-basic' as Mode, 0 ], [ Mock.of({ version: '2.6.0' }), 'create' as Mode, 4 ], [ Mock.of({ version: '2.5.0' }), 'create' as Mode, 4 ], - [ Mock.of({ version: '2.4.0' }), 'create' as Mode, 4 ], - [ Mock.of({ version: '2.3.0' }), 'create' as Mode, 4 ], [ Mock.of({ version: '2.6.0' }), 'edit' as Mode, 4 ], [ Mock.of({ version: '2.5.0' }), 'edit' as Mode, 3 ], - [ Mock.of({ version: '2.4.0' }), 'edit' as Mode, 3 ], - [ Mock.of({ version: '2.3.0' }), 'edit' as Mode, 2 ], ])( 'renders expected amount of cards based on server capabilities and mode', (selectedServer, mode, expectedAmountOfCards) => { diff --git a/test/short-urls/helpers/QrCodeModal.test.tsx b/test/short-urls/helpers/QrCodeModal.test.tsx index aa8b5160..900e90e2 100644 --- a/test/short-urls/helpers/QrCodeModal.test.tsx +++ b/test/short-urls/helpers/QrCodeModal.test.tsx @@ -43,9 +43,6 @@ describe('', () => { }); it.each([ - [ '2.3.0' as SemVer, 0, '/qr-code/300' ], - [ '2.4.0' as SemVer, 0, '/qr-code/300?format=png' ], - [ '2.4.0' as SemVer, 10, '/qr-code/300?format=png' ], [ '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' ], @@ -90,8 +87,6 @@ describe('', () => { }); it.each([ - [ '2.3.0' as SemVer, 0, 'col-12' ], - [ '2.4.0' as SemVer, 1, 'col-md-6' ], [ '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) => { diff --git a/test/utils/helpers/qrCodes.test.ts b/test/utils/helpers/qrCodes.test.ts index c9d7bcbe..5a2edf22 100644 --- a/test/utils/helpers/qrCodes.test.ts +++ b/test/utils/helpers/qrCodes.test.ts @@ -6,67 +6,67 @@ describe('qrCodes', () => { [ 'foo.com', { size: 530, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + { useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.com/qr-code/530?format=svg', ], [ 'foo.com', { size: 530, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + { useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false }, 'foo.com/qr-code/530?format=png', ], [ 'bar.io', { size: 870, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: false, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, - 'bar.io/qr-code?size=870', + { useSizeInPath: false, marginIsSupported: false, errorCorrectionIsSupported: false }, + 'bar.io/qr-code?size=870&format=svg', ], [ 'bar.io', { size: 200, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + { useSizeInPath: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=png', ], [ 'bar.io', { size: 200, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: false, svgIsSupported: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + { useSizeInPath: false, marginIsSupported: false, errorCorrectionIsSupported: false }, 'bar.io/qr-code?size=200&format=svg', ], [ 'foo.net', { size: 480, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, - 'foo.net/qr-code/480', + { useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + 'foo.net/qr-code/480?format=png', ], [ 'foo.net', { size: 480, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, - 'foo.net/qr-code/480', + { useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + 'foo.net/qr-code/480?format=svg', ], [ 'shlink.io', { size: 123, format: 'svg' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: false, marginIsSupported: false, errorCorrectionIsSupported: false }, - 'shlink.io/qr-code/123', + { useSizeInPath: true, marginIsSupported: false, errorCorrectionIsSupported: false }, + 'shlink.io/qr-code/123?format=svg', ], [ 'shlink.io', { size: 456, format: 'png' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: false }, + { useSizeInPath: true, marginIsSupported: true, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/456?format=png&margin=10', ], [ 'shlink.io', { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: false }, + { useSizeInPath: true, marginIsSupported: true, errorCorrectionIsSupported: false }, 'shlink.io/qr-code/456?format=png', ], [ 'shlink.io', { size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'H' as QrErrorCorrection }, - { useSizeInPath: true, svgIsSupported: true, marginIsSupported: true, errorCorrectionIsSupported: true }, + { useSizeInPath: true, marginIsSupported: true, errorCorrectionIsSupported: true }, 'shlink.io/qr-code/456?format=png&errorCorrection=H', ], ])('builds expected URL based in params', (shortUrl, options, capabilities, expectedUrl) => { From 8b5b035568929f5383c66824d50b7e8111384b0f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Sep 2021 11:47:18 +0200 Subject: [PATCH 75/76] Removed rest of version checks for versions older than 2.4 --- src/servers/Overview.tsx | 9 +-------- src/tags/TagCard.tsx | 13 ++----------- src/tags/services/provideServices.ts | 9 +-------- test/servers/Overview.test.tsx | 7 ------- test/tags/TagCard.test.tsx | 11 ++++++----- 5 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 84614b2e..99311133 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -55,14 +55,7 @@ export const Overview = (
Visits - - - {loadingVisits ? 'Loading...' : prettify(visitsCount)} - - - Shlink 2.2 is needed - - + {loadingVisits ? 'Loading...' : prettify(visitsCount)}
diff --git a/src/tags/TagCard.tsx b/src/tags/TagCard.tsx index 6bad3281..e3cd220e 100644 --- a/src/tags/TagCard.tsx +++ b/src/tags/TagCard.tsx @@ -5,7 +5,6 @@ import { FC, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { prettify } from '../utils/helpers/numbers'; import { useToggle } from '../utils/helpers/hooks'; -import { Versions } from '../utils/helpers/version'; import ColorGenerator from '../utils/services/ColorGenerator'; import { isServerWithId, SelectedServer } from '../servers/data'; import TagBullet from './helpers/TagBullet'; @@ -25,16 +24,13 @@ const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollW const TagCard = ( DeleteTagConfirmModal: FC, EditTagModal: FC, - ForServerVersion: FC, colorGenerator: ColorGenerator, ) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => { const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isEditModalOpen, toggleEdit ] = useToggle(); const [ hasTitle,, displayTitle ] = useToggle(); const titleRef = useRef(); - const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; - const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`; useEffect(() => { if (isTruncated(titleRef.current)) { @@ -59,12 +55,7 @@ const TagCard = ( }} > - - {tag} - - - {tag} - + {tag}
@@ -72,7 +63,7 @@ const TagCard = ( Short URLs diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index ac1753dc..1e3e8d39 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -18,14 +18,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ])); - bottle.serviceFactory( - 'TagCard', - TagCard, - 'DeleteTagConfirmModal', - 'EditTagModal', - 'ForServerVersion', - 'ColorGenerator', - ); + bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal); bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ])); diff --git a/test/servers/Overview.test.tsx b/test/servers/Overview.test.tsx index 3b29b3e4..14cc85b1 100644 --- a/test/servers/Overview.test.tsx +++ b/test/servers/Overview.test.tsx @@ -64,13 +64,6 @@ describe('', () => { expect(cards.at(3).html()).toContain(prettify(3)); }); - it('displays warning in first card for old shlink versions', () => { - const wrapper = createWrapper(); - const firstCard = wrapper.find(CardText).first(); - - expect(firstCard.html()).toContain('Shlink 2.2 is needed'); - }); - it('nests complex components', () => { const wrapper = createWrapper(); diff --git a/test/tags/TagCard.test.tsx b/test/tags/TagCard.test.tsx index db1dce18..8bdd6c6c 100644 --- a/test/tags/TagCard.test.tsx +++ b/test/tags/TagCard.test.tsx @@ -14,7 +14,7 @@ describe('', () => { }; const DeleteTagConfirmModal = jest.fn(); const EditTagModal = jest.fn(); - const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, () => null, Mock.all()); + const TagCard = createTagCard(DeleteTagConfirmModal, EditTagModal, Mock.all()); const createWrapper = (tag = 'ssr') => { wrapper = shallow( ', () => { it('shows expected tag stats', () => { const links = wrapper.find(Link); - expect(links.at(1).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr'); - expect(links.at(1).text()).toContain('48'); - expect(links.at(2).prop('to')).toEqual('/server/1/tag/ssr/visits'); - expect(links.at(2).text()).toContain('23,257'); + expect(links).toHaveLength(2); + expect(links.at(0).prop('to')).toEqual('/server/1/list-short-urls/1?tag=ssr'); + expect(links.at(0).text()).toContain('48'); + expect(links.at(1).prop('to')).toEqual('/server/1/tag/ssr/visits'); + expect(links.at(1).text()).toContain('23,257'); }); }); From 9b32bd2817fa76ac8f3a2b9af8f2dc1419137044 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Sep 2021 11:48:25 +0200 Subject: [PATCH 76/76] Updated changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 958c0192..0a733642 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.3.0] - 2021-09-25 ### Added * [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher. * [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title. @@ -31,7 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * *Nothing* ### Removed -* *Nothing* +* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0. ### Fixed * *Nothing*