From fb961dd47b1d89b402c381216d4768a14c839308 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Dec 2021 23:57:30 +0000 Subject: [PATCH 01/49] Bump axios from 0.21.1 to 0.21.2 Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 35 ++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7e92e210..edbb0d32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/react-fontawesome": "^0.1.14", - "axios": "^0.21.1", + "axios": "^0.21.2", "bootstrap": "^4.6.0", "bottlejs": "^2.0.0", "bowser": "^2.11.0", @@ -6470,11 +6470,11 @@ } }, "node_modules/axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz", + "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==", "dependencies": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.0" } }, "node_modules/axobject-query": { @@ -13791,9 +13791,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==", "funding": [ { "type": "individual", @@ -13802,6 +13802,11 @@ ], "engines": { "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, "node_modules/for-in": { @@ -39831,11 +39836,11 @@ "dev": true }, "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz", + "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==", "requires": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.0" } }, "axobject-query": { @@ -45629,9 +45634,9 @@ } }, "follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" }, "for-in": { "version": "1.0.2", diff --git a/package.json b/package.json index 2385201b..f1cae125 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/react-fontawesome": "^0.1.14", - "axios": "^0.21.1", + "axios": "^0.21.2", "bootstrap": "^4.6.0", "bottlejs": "^2.0.0", "bowser": "^2.11.0", From 79e54ea2308301088b980f76308606361f1a1a2b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 8 Dec 2021 08:53:10 +0100 Subject: [PATCH 02/49] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c09131..9b550bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * *Nothing* ### Changed -* *Nothing* +* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. ### Deprecated * *Nothing* From 3f1392ce62c8c794d9df63fc03ba071d297c1194 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Dec 2021 13:08:19 +0100 Subject: [PATCH 03/49] Fixed changelog --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b550bb5..2f322c81 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). -## [3.4.2] - 2021-12-07 +## [Unreleased] ### Added * *Nothing* @@ -17,6 +17,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Removed * *Nothing* +### Fixed +* *Nothing* + + +## [3.4.2] - 2021-12-07 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + ### Fixed * [#530](https://github.com/shlinkio/shlink-web-client/issues/530) Fixed crash on domains page when default domain has an explicitly set port. From 8d476e072961ce15abd412b1f673b8d7979897b1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Dec 2021 13:16:28 +0100 Subject: [PATCH 04/49] Added support to fetch full response from list domains endpoint --- src/api/services/ShlinkApiClient.ts | 5 ++--- src/api/types/index.ts | 1 + src/domains/reducers/domainsList.ts | 2 +- test/api/services/ShlinkApiClient.test.ts | 6 ++---- test/domains/reducers/domainsList.test.ts | 2 +- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 6c783531..35ebebad 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -11,7 +11,6 @@ import { ShlinkVisits, ShlinkVisitsParams, ShlinkShortUrlData, - ShlinkDomain, ShlinkDomainsResponse, ShlinkVisitsOverview, ShlinkEditDomainRedirects, @@ -107,8 +106,8 @@ export default class ShlinkApiClient { this.performRequest('/mercure-info', 'GET') .then((resp) => resp.data); - public readonly listDomains = async (): Promise => - this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data); + public readonly listDomains = async (): Promise => + this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains); public readonly editDomainRedirects = async ( domainRedirects: ShlinkEditDomainRedirects, diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 478194fc..c5d11a19 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -84,6 +84,7 @@ export interface ShlinkDomain { export interface ShlinkDomainsResponse { data: ShlinkDomain[]; + defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10 } export interface ShlinkShortUrlsListParams { diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index c8ee513d..4a902ba9 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -68,7 +68,7 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () const { listDomains } = buildShlinkApiClient(getState); try { - const domains = await listDomains(); + const { data: domains } = await listDomains(); dispatch({ type: LIST_DOMAINS, domains }); } catch (e: any) { diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 95bf899f..82efa569 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -256,10 +256,8 @@ describe('ShlinkApiClient', () => { describe('listDomains', () => { it('returns domains', async () => { - const expectedData = [ Mock.all(), Mock.all() ]; - const resp = { - domains: { data: expectedData }, - }; + const expectedData = { data: [ Mock.all(), Mock.all() ] }; + const resp = { domains: expectedData }; const axiosSpy = createAxiosMock({ data: resp }); const { listDomains } = new ShlinkApiClient(axiosSpy, '', ''); diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 5bc1eb9c..ac3066b0 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -88,7 +88,7 @@ describe('domainsList', () => { }); it('dispatches domains once loaded', async () => { - listDomains.mockResolvedValue(domains); + listDomains.mockResolvedValue({ data: domains }); await listDomainsAction(buildShlinkApiClient)()(dispatch, getState); From c9d906316febc549bdc66eb900c79c76d7a334e2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Dec 2021 13:44:29 +0100 Subject: [PATCH 05/49] Updated domain components to use defaultRedirects prop when present (Shlink 2.10 or newer) --- src/domains/DomainRow.tsx | 14 +++-- src/domains/ManageDomains.tsx | 11 ++-- src/domains/reducers/domainsList.ts | 9 ++-- src/domains/services/provideServices.ts | 2 +- src/utils/helpers/features.ts | 2 + test/domains/DomainRow.test.tsx | 63 +++++++++++++++++------ test/domains/ManageDomains.test.tsx | 2 + test/domains/reducers/domainsList.test.ts | 4 +- 8 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index 2c6bdef9..1a2f67ea 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -9,12 +9,15 @@ import { import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types'; import { useToggle } from '../utils/helpers/hooks'; import { OptionalString } from '../utils/utils'; +import { SelectedServer } from '../servers/data'; +import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features'; import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; interface DomainRowProps { domain: ShlinkDomain; defaultRedirects?: ShlinkDomainRedirects; editDomainRedirects: (domain: string, redirects: Partial) => Promise; + selectedServer: SelectedServer; } const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( @@ -30,9 +33,10 @@ const DefaultDomain: FC = () => ( ); -export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects }) => { +export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects, selectedServer }) => { const [ isOpen, toggle ] = useToggle(); const { domain: authority, isDefault, redirects } = domain; + const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); return ( @@ -48,12 +52,12 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def {redirects?.invalidShortUrlRedirect ?? } - - - {isDefault && ( + {!canEditDomain && ( Redirects for default domain cannot be edited here.
diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 69f67a30..1466a7a2 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -5,6 +5,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; import SearchField from '../utils/SearchField'; import { ShlinkDomainRedirects } from '../api/types'; +import { SelectedServer } from '../servers/data'; import { DomainsList } from './reducers/domainsList'; import { DomainRow } from './DomainRow'; @@ -13,15 +14,16 @@ interface ManageDomainsProps { filterDomains: (searchTerm: string) => void; editDomainRedirects: (domain: string, redirects: Partial) => Promise; domainsList: DomainsList; + selectedServer: SelectedServer; } const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; export const ManageDomains: FC = ( - { listDomains, domainsList, filterDomains, editDomainRedirects }, + { listDomains, domainsList, filterDomains, editDomainRedirects, selectedServer }, ) => { - const { filteredDomains: domains, loading, error, errorData } = domainsList; - const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects; + const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList; + const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects; useEffect(() => { listDomains(); @@ -53,7 +55,8 @@ export const ManageDomains: FC = ( key={domain.domain} domain={domain} editDomainRedirects={editDomainRedirects} - defaultRedirects={defaultRedirects} + defaultRedirects={resolvedDefaultRedirects} + selectedServer={selectedServer} /> ))} diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 4a902ba9..6ade3fb3 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -17,6 +17,7 @@ export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; export interface DomainsList { domains: ShlinkDomain[]; filteredDomains: ShlinkDomain[]; + defaultRedirects?: ShlinkDomainRedirects; loading: boolean; error: boolean; errorData?: ProblemDetailsError; @@ -24,6 +25,7 @@ export interface DomainsList { export interface ListDomainsAction extends Action { domains: ShlinkDomain[]; + defaultRedirects?: ShlinkDomainRedirects; } interface FilterDomainsAction extends Action { @@ -48,7 +50,8 @@ export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomain export default buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), [LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }), - [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }), + [LIST_DOMAINS]: (_, { domains, defaultRedirects }) => + ({ ...initialState, domains, filteredDomains: domains, defaultRedirects }), [FILTER_DOMAINS]: (state, { searchTerm }) => ({ ...state, filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)), @@ -68,9 +71,9 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () const { listDomains } = buildShlinkApiClient(getState); try { - const { data: domains } = await listDomains(); + const { data: domains, defaultRedirects } = await listDomains(); - dispatch({ type: LIST_DOMAINS, domains }); + dispatch({ type: LIST_DOMAINS, domains, defaultRedirects }); } catch (e: any) { dispatch({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); } diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index e6f01b1b..55331860 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -12,7 +12,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ManageDomains', () => ManageDomains); bottle.decorator('ManageDomains', connect( - [ 'domainsList' ], + [ 'domainsList', 'selectedServer' ], [ 'listDomains', 'filterDomains', 'editDomainRedirects' ], )); diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index d424e512..8706a0eb 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -23,3 +23,5 @@ export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2. export const supportsDomainRedirects = supportsQrErrorCorrection; export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' }); + +export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' }); diff --git a/test/domains/DomainRow.test.tsx b/test/domains/DomainRow.test.tsx index 98232aa9..2034ad7e 100644 --- a/test/domains/DomainRow.test.tsx +++ b/test/domains/DomainRow.test.tsx @@ -5,11 +5,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; import { ShlinkDomain, ShlinkDomainRedirects } from '../../src/api/types'; import { DomainRow } from '../../src/domains/DomainRow'; +import { ReachableServer, SelectedServer } from '../../src/servers/data'; describe('', () => { let wrapper: ShallowWrapper; - const createWrapper = (domain: ShlinkDomain) => { - wrapper = shallow(); + const createWrapper = (domain: ShlinkDomain, selectedServer = Mock.all()) => { + wrapper = shallow(); return wrapper; }; @@ -17,28 +18,60 @@ describe('', () => { afterEach(() => wrapper?.unmount()); it.each([ - [ Mock.of({ domain: '', isDefault: true }), 1, 'defaultDomainBtn' ], - [ Mock.of({ domain: '', isDefault: false }), 0, undefined ], - [ Mock.of({ domain: 'foo.com', isDefault: true }), 1, 'defaultDomainBtn' ], - [ Mock.of({ domain: 'foo.bar.com', isDefault: true }), 1, 'defaultDomainBtn' ], - [ Mock.of({ domain: 'foo.baz', isDefault: false }), 0, undefined ], - ])('shows proper components based on the fact that provided domain is default or not', ( + [ Mock.of({ domain: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ], + [ Mock.of({ domain: '', isDefault: false }), undefined, 0, 0, undefined ], + [ Mock.of({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ], + [ Mock.of({ domain: 'foo.bar.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ], + [ Mock.of({ domain: 'foo.baz', isDefault: false }), undefined, 0, 0, undefined ], + [ + Mock.of({ domain: 'foo.baz', isDefault: true }), + Mock.of({ version: '2.10.0' }), + 1, + 0, + undefined, + ], + [ + Mock.of({ domain: 'foo.baz', isDefault: true }), + Mock.of({ version: '2.9.0' }), + 1, + 1, + 'defaultDomainBtn', + ], + [ + Mock.of({ domain: 'foo.baz', isDefault: false }), + Mock.of({ version: '2.9.0' }), + 0, + 0, + undefined, + ], + [ + Mock.of({ domain: 'foo.baz', isDefault: false }), + Mock.of({ version: '2.10.0' }), + 0, + 0, + undefined, + ], + ])('shows proper components based on provided domain and selectedServer', ( domain, - expectedComps, + selectedServer, + expectedDefaultDomainIcons, + expectedDisabledComps, expectedDomainId, ) => { - const wrapper = createWrapper(domain); + const wrapper = createWrapper(domain, selectedServer); const defaultDomainComp = wrapper.find('td').first().find('DefaultDomain'); + const disabledBtn = wrapper.find(Button).findWhere((btn) => !!btn.prop('disabled')); const tooltip = wrapper.find(UncontrolledTooltip); const button = wrapper.find(Button); const icon = wrapper.find(FontAwesomeIcon); - expect(defaultDomainComp).toHaveLength(expectedComps); - expect(button.prop('disabled')).toEqual(domain.isDefault); - expect(icon.prop('icon')).toEqual(domain.isDefault ? forbiddenIcon : editIcon); - expect(tooltip).toHaveLength(expectedComps); + expect(defaultDomainComp).toHaveLength(expectedDefaultDomainIcons); + expect(disabledBtn).toHaveLength(expectedDisabledComps); + expect(button.prop('disabled')).toEqual(expectedDisabledComps > 0); + expect(icon.prop('icon')).toEqual(expectedDisabledComps > 0 ? forbiddenIcon : editIcon); + expect(tooltip).toHaveLength(expectedDisabledComps); - if (expectedComps > 0) { + if (expectedDisabledComps > 0) { expect(tooltip.prop('target')).toEqual(expectedDomainId); } }); diff --git a/test/domains/ManageDomains.test.tsx b/test/domains/ManageDomains.test.tsx index d5006935..6c5180e8 100644 --- a/test/domains/ManageDomains.test.tsx +++ b/test/domains/ManageDomains.test.tsx @@ -8,6 +8,7 @@ import SearchField from '../../src/utils/SearchField'; import { ProblemDetailsError, ShlinkDomain } from '../../src/api/types'; import { ShlinkApiError } from '../../src/api/ShlinkApiError'; import { DomainRow } from '../../src/domains/DomainRow'; +import { SelectedServer } from '../../src/servers/data'; describe('', () => { const listDomains = jest.fn(); @@ -21,6 +22,7 @@ describe('', () => { filterDomains={filterDomains} editDomainRedirects={editDomainRedirects} domainsList={domainsList} + selectedServer={Mock.all()} />, ); diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index ac3066b0..83845197 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -14,7 +14,7 @@ import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedir import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; -describe('domainsList', () => { +describe('domainsListReducer', () => { const filteredDomains = [ Mock.of({ domain: 'foo' }), Mock.of({ domain: 'boo' }) ]; const domains = [ ...filteredDomains, Mock.of({ domain: 'bar' }) ]; @@ -94,7 +94,7 @@ describe('domainsList', () => { expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains, defaultRedirects: undefined }); expect(listDomains).toHaveBeenCalledTimes(1); }); }); From 9abbfc5b1e0afb63b3da4cdd7541d426b904bd60 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 9 Dec 2021 13:45:24 +0100 Subject: [PATCH 06/49] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f322c81..1977719c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added -* *Nothing* +* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. From 17e4e06fcc323116f9c3edaec0b7dc72c1397b9e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Dec 2021 23:01:19 +0100 Subject: [PATCH 07/49] Switched to the - notation in orderBy param for short URLs list --- CHANGELOG.md | 1 + src/api/services/ShlinkApiClient.ts | 17 ++++++++++++++++- src/api/types/index.ts | 4 ++++ test/api/services/ShlinkApiClient.test.ts | 22 ++++++++++++++++++++-- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1977719c..9617821c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. +* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `-` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0 ### Deprecated * *Nothing* diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 35ebebad..a21bfeeb 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -16,11 +16,26 @@ import { ShlinkEditDomainRedirects, ShlinkDomainRedirects, ShlinkShortUrlsListParams, + ShlinkShortUrlsListNormalizedParams, } from '../types'; import { stringifyQuery } from '../../utils/helpers/query'; const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; const rejectNilProps = reject(isNil); +const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { + if (!params.orderBy) { + return params as ShlinkShortUrlsListNormalizedParams; + } + + const { orderBy, ...rest } = params; + const [ firstKey ] = Object.keys(orderBy); + const [ firstValue ] = Object.values(orderBy); + + return !firstValue ? rest : { + ...rest, + orderBy: `${firstKey}-${firstValue}`, + }; +}; export default class ShlinkApiClient { private apiVersion: number; @@ -34,7 +49,7 @@ export default class ShlinkApiClient { } public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => - this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params) + this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params)) .then(({ data }) => data.shortUrls); public readonly createShortUrl = async (options: ShortUrlData): Promise => { diff --git a/src/api/types/index.ts b/src/api/types/index.ts index c5d11a19..cce4751c 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -97,6 +97,10 @@ export interface ShlinkShortUrlsListParams { orderBy?: OrderBy; } +export interface ShlinkShortUrlsListNormalizedParams extends Omit { + orderBy?: string; +} + export interface ProblemDetailsError { type: string; detail: string; diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 82efa569..10090847 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -5,6 +5,7 @@ import { OptionalString } from '../../../src/utils/utils'; import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; import { ShortUrl } from '../../../src/short-urls/data'; import { Visit } from '../../../src/visits/types'; +import { OrderDir } from '../../../src/utils/helpers/ordering'; describe('ShlinkApiClient', () => { const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; @@ -17,9 +18,9 @@ describe('ShlinkApiClient', () => { ]; describe('listShortUrls', () => { - it('properly returns short URLs list', async () => { - const expectedList = [ 'foo', 'bar' ]; + const expectedList = [ 'foo', 'bar' ]; + it('properly returns short URLs list', async () => { const { listShortUrls } = createApiClient({ data: { shortUrls: expectedList, @@ -30,6 +31,23 @@ describe('ShlinkApiClient', () => { expect(expectedList).toEqual(actualList); }); + + it.each([ + [{ visits: 'DESC' as OrderDir }, 'visits-DESC' ], + [{ longUrl: 'ASC' as OrderDir }, 'longUrl-ASC' ], + [{ longUrl: undefined as OrderDir }, undefined ], + ])('parses orderBy in params', async (orderBy, expectedOrderBy) => { + const axiosSpy = createAxiosMock({ + data: expectedList, + }); + const { listShortUrls } = new ShlinkApiClient(axiosSpy, '', ''); + + await listShortUrls({ orderBy }); + + expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ + params: { orderBy: expectedOrderBy }, + })); + }); }); describe('createShortUrl', () => { From 7d6afd47b141e831f0c25dfff260e00da7b2ad0b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Dec 2021 23:12:39 +0100 Subject: [PATCH 08/49] Removed unecesary check --- src/api/services/ShlinkApiClient.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index a21bfeeb..192bad0f 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -23,11 +23,7 @@ import { stringifyQuery } from '../../utils/helpers/query'; const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; const rejectNilProps = reject(isNil); const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { - if (!params.orderBy) { - return params as ShlinkShortUrlsListNormalizedParams; - } - - const { orderBy, ...rest } = params; + const { orderBy = {}, ...rest } = params; const [ firstKey ] = Object.keys(orderBy); const [ firstValue ] = Object.values(orderBy); From 138e40315ddab412f69240c34d586fbeb63cdfb5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 19 Dec 2021 12:52:49 +0100 Subject: [PATCH 09/49] Added custom slug field to the basic creation form in Overview page --- CHANGELOG.md | 1 + src/short-urls/ShortUrlForm.tsx | 25 +++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9617821c..3cb6cea1 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 * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. +* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 64caeec9..a8634173 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -41,6 +41,7 @@ export const ShortUrlForm = ( ): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { const [ shortUrlData, setShortUrlData ] = useState(initialState); const isEdit = mode === 'edit'; + const isBasicMode = mode === 'create-basic'; const hadTitleOriginally = hasValue(initialState.title); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const reset = () => setShortUrlData(initialState); @@ -66,8 +67,14 @@ export const ShortUrlForm = ( setShortUrlData(initialState); }, [ initialState ]); - const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => ( - + const renderOptionalInput = ( + id: NonDateFields, + placeholder: string, + type: InputType = 'text', + props = {}, + fromGroupProps = {}, + ) => ( + setShortUrlData({ ...shortUrlData, longUrl: e.target.value })} /> - - - - + + {isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })} + + + + ); @@ -118,8 +127,8 @@ export const ShortUrlForm = ( return (
- {mode === 'create-basic' && basicComponents} - {mode !== 'create-basic' && ( + {isBasicMode && basicComponents} + {!isBasicMode && ( <> {basicComponents} From 7adb40489d7fbbabf8c4482e3dfafaba83492a29 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Dec 2021 20:08:28 +0100 Subject: [PATCH 10/49] Added some helper function to deal with dates --- src/utils/dates/types/index.ts | 25 ++++++++++--- src/utils/helpers/date.ts | 26 ++++++-------- src/utils/helpers/hooks.ts | 11 +++++- src/visits/types/index.ts | 7 +++- test/settings/Visits.test.tsx | 4 +-- test/settings/reducers/settings.test.ts | 4 +-- .../dates/DateIntervalDropdownItems.test.tsx | 2 +- test/utils/dates/types/index.test.ts | 35 ++++++++++++++++--- test/utils/helpers/date.test.ts | 34 ++++++++++++++++-- 9 files changed, 114 insertions(+), 34 deletions(-) diff --git a/src/utils/dates/types/index.ts b/src/utils/dates/types/index.ts index 467a0e8f..6041a68f 100644 --- a/src/utils/dates/types/index.ts +++ b/src/utils/dates/types/index.ts @@ -1,13 +1,13 @@ import { subDays, startOfDay, endOfDay } from 'date-fns'; -import { filter, isEmpty } from 'ramda'; -import { formatInternational } from '../../helpers/date'; +import { cond, filter, isEmpty, T } from 'ramda'; +import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date'; export interface DateRange { startDate?: Date | null; endDate?: Date | null; } -export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days'; +export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180Days' | 'last365Days'; export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined || isEmpty(filter(Boolean, dateRange as any)); @@ -21,7 +21,7 @@ const INTERVAL_TO_STRING_MAP: Record = { last7Days: 'Last 7 days', last30Days: 'Last 30 days', last90Days: 'Last 90 days', - last180days: 'Last 180 days', + last180Days: 'Last 180 days', last365Days: 'Last 365 days', all: undefined, }; @@ -75,7 +75,7 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { return endingToday(startOfDaysAgo(30)); case 'last90Days': return endingToday(startOfDaysAgo(90)); - case 'last180days': + case 'last180Days': return endingToday(startOfDaysAgo(180)); case 'last365Days': return endingToday(startOfDaysAgo(365)); @@ -83,3 +83,18 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { return {}; }; + +export const dateToMatchingInterval = (date: DateOrString): DateInterval => { + const theDate: Date = parseISO(date); + + return cond([ + [ () => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today' ], + [ () => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday' ], + [ () => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days' ], + [ T, () => 'all' ], + ])(); +}; diff --git a/src/utils/helpers/date.ts b/src/utils/helpers/date.ts index 5ce27ae3..98aa1d4d 100644 --- a/src/utils/helpers/date.ts +++ b/src/utils/helpers/date.ts @@ -1,7 +1,8 @@ -import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns'; +import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns'; import { OptionalString } from '../utils'; -type DateOrString = Date | string; +export type DateOrString = Date | string; + type NullableDate = DateOrString | null; export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string'; @@ -22,20 +23,15 @@ export const formatInternational = formatDate(); export const parseDate = (date: string, format: string) => parse(date, format, new Date()); -const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date); +export const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date); export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => { - if (!start && end) { - return isBefore(parseISO(date), parseISO(end)); + try { + return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) }); + } catch (e) { + return false; } - - if (start && !end) { - return isAfter(parseISO(date), parseISO(start)); - } - - if (start && end) { - return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) }); - } - - return true; }; + +export const isBeforeOrEqual = (date: Date | number, dateToCompare: Date | number) => + isEqual(date, dateToCompare) || isBefore(date, dateToCompare); diff --git a/src/utils/helpers/hooks.ts b/src/utils/helpers/hooks.ts index 6e9548ad..7d908852 100644 --- a/src/utils/helpers/hooks.ts +++ b/src/utils/helpers/hooks.ts @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react'; import { useSwipeable as useReactSwipeable } from 'react-swipeable'; import { parseQuery, stringifyQuery } from './query'; @@ -66,3 +66,12 @@ export const useQueryState = (paramName: string, initialState: T): [ T, (newV return [ value, setValueWithLocation ]; }; + +export const useEffectExceptFirstTime = (callback: EffectCallback, deps: DependencyList): void => { + const isFirstLoad = useRef(true); + + useEffect(() => { + !isFirstLoad.current && callback(); + isFirstLoad.current = false; + }, deps); +}; diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 05f8226b..03789a28 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,7 +1,7 @@ import { Action } from 'redux'; import { ShortUrl } from '../../short-urls/data'; import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types'; -import { DateRange } from '../../utils/dates/types'; +import { DateInterval, DateRange } from '../../utils/dates/types'; export interface VisitsInfo { visits: Visit[]; @@ -12,12 +12,17 @@ export interface VisitsInfo { progress: number; cancelLoad: boolean; query?: ShlinkVisitsParams; + fallbackInterval?: DateInterval; } export interface VisitsLoadProgressChangedAction extends Action { progress: number; } +export interface VisitsFallbackIntervalAction extends Action { + fallbackInterval: DateInterval; +} + export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { diff --git a/test/settings/Visits.test.tsx b/test/settings/Visits.test.tsx index cfbaf833..89a73546 100644 --- a/test/settings/Visits.test.tsx +++ b/test/settings/Visits.test.tsx @@ -55,12 +55,12 @@ describe('', () => { const selector = wrapper.find(DateIntervalSelector); selector.simulate('change', 'last7Days'); - selector.simulate('change', 'last180days'); + selector.simulate('change', 'last180Days'); selector.simulate('change', 'yesterday'); expect(setVisitsSettings).toHaveBeenCalledTimes(3); expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' }); - expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180days' }); + expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' }); }); }); diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 9699f57a..70c5d4b7 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -54,9 +54,9 @@ describe('settingsReducer', () => { describe('setVisitsSettings', () => { it('creates action to set visits settings', () => { - const result = setVisitsSettings({ defaultInterval: 'last180days' }); + const result = setVisitsSettings({ defaultInterval: 'last180Days' }); - expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180days' } }); + expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180Days' } }); }); }); }); diff --git a/test/utils/dates/DateIntervalDropdownItems.test.tsx b/test/utils/dates/DateIntervalDropdownItems.test.tsx index d77fb1e4..8587e5e9 100644 --- a/test/utils/dates/DateIntervalDropdownItems.test.tsx +++ b/test/utils/dates/DateIntervalDropdownItems.test.tsx @@ -8,7 +8,7 @@ describe('', () => { const onChange = jest.fn(); beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); afterEach(jest.clearAllMocks); diff --git a/test/utils/dates/types/index.test.ts b/test/utils/dates/types/index.test.ts index fa7dc1d4..21cec68f 100644 --- a/test/utils/dates/types/index.test.ts +++ b/test/utils/dates/types/index.test.ts @@ -1,7 +1,8 @@ -import { format, subDays } from 'date-fns'; +import { endOfDay, format, formatISO, startOfDay, subDays } from 'date-fns'; import { DateInterval, dateRangeIsEmpty, + dateToMatchingInterval, intervalToDateRange, rangeIsInterval, rangeOrIntervalToString, @@ -9,6 +10,9 @@ import { import { parseDate } from '../../../../src/utils/helpers/date'; describe('date-types', () => { + const now = () => new Date(); + const daysBack = (days: number) => subDays(new Date(), days); + describe('dateRangeIsEmpty', () => { it.each([ [ undefined, true ], @@ -48,7 +52,7 @@ describe('date-types', () => { [ 'last7Days' as DateInterval, 'Last 7 days' ], [ 'last30Days' as DateInterval, 'Last 30 days' ], [ 'last90Days' as DateInterval, 'Last 90 days' ], - [ 'last180days' as DateInterval, 'Last 180 days' ], + [ 'last180Days' as DateInterval, 'Last 180 days' ], [ 'last365Days' as DateInterval, 'Last 365 days' ], [{}, undefined ], [{ startDate: null }, undefined ], @@ -71,8 +75,6 @@ describe('date-types', () => { }); describe('intervalToDateRange', () => { - const now = () => new Date(); - const daysBack = (days: number) => subDays(new Date(), days); const formatted = (date?: Date | null): string | undefined => !date ? undefined : format(date, 'yyyy-MM-dd'); it.each([ @@ -82,7 +84,7 @@ describe('date-types', () => { [ 'last7Days' as DateInterval, daysBack(7), now() ], [ 'last30Days' as DateInterval, daysBack(30), now() ], [ 'last90Days' as DateInterval, daysBack(90), now() ], - [ 'last180days' as DateInterval, daysBack(180), now() ], + [ 'last180Days' as DateInterval, daysBack(180), now() ], [ 'last365Days' as DateInterval, daysBack(365), now() ], ])('returns proper result', (interval, expectedStartDate, expectedEndDate) => { const { startDate, endDate } = intervalToDateRange(interval); @@ -91,4 +93,27 @@ describe('date-types', () => { expect(formatted(expectedEndDate)).toEqual(formatted(endDate)); }); }); + + describe('dateToMatchingInterval', () => { + it.each([ + [ startOfDay(now()), 'today' ], + [ now(), 'today' ], + [ formatISO(now()), 'today' ], + [ daysBack(1), 'yesterday' ], + [ endOfDay(daysBack(1)), 'yesterday' ], + [ daysBack(2), 'last7Days' ], + [ daysBack(7), 'last7Days' ], + [ startOfDay(daysBack(7)), 'last7Days' ], + [ daysBack(18), 'last30Days' ], + [ daysBack(29), 'last30Days' ], + [ daysBack(58), 'last90Days' ], + [ startOfDay(daysBack(90)), 'last90Days' ], + [ daysBack(120), 'last180Days' ], + [ daysBack(250), 'last365Days' ], + [ daysBack(366), 'all' ], + [ formatISO(daysBack(500)), 'all' ], + ])('returns the first interval which contains provided date', (date, expectedInterval) => { + expect(dateToMatchingInterval(date)).toEqual(expectedInterval); + }); + }); }); diff --git a/test/utils/helpers/date.test.ts b/test/utils/helpers/date.test.ts index ac0444a7..349fae49 100644 --- a/test/utils/helpers/date.test.ts +++ b/test/utils/helpers/date.test.ts @@ -1,7 +1,9 @@ -import { formatISO } from 'date-fns'; -import { formatDate, formatIsoDate, parseDate } from '../../../src/utils/helpers/date'; +import { addDays, formatISO, subDays } from 'date-fns'; +import { formatDate, formatIsoDate, isBeforeOrEqual, isBetween, parseDate } from '../../../src/utils/helpers/date'; describe('date', () => { + const now = new Date(); + describe('formatDate', () => { it.each([ [ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'dd/MM/yyyy', '05/03/2020' ], @@ -30,4 +32,32 @@ describe('date', () => { expect(formatIsoDate(date)).toEqual(expected); }); }); + + describe('isBetween', () => { + test.each([ + [ now, undefined, undefined, true ], + [ now, subDays(now, 1), undefined, true ], + [ now, now, undefined, true ], + [ now, undefined, addDays(now, 1), true ], + [ now, undefined, now, true ], + [ now, subDays(now, 1), addDays(now, 1), true ], + [ now, now, now, true ], + [ now, addDays(now, 1), undefined, false ], + [ now, undefined, subDays(now, 1), false ], + [ now, subDays(now, 3), subDays(now, 1), false ], + [ now, addDays(now, 1), addDays(now, 3), false ], + ])('returns true when a date is between provided range', (date, start, end, expectedResult) => { + expect(isBetween(date, start, end)).toEqual(expectedResult); + }); + }); + + describe('isBeforeOrEqual', () => { + test.each([ + [ now, now, true ], + [ now, addDays(now, 1), true ], + [ now, subDays(now, 1), false ], + ])('returns true when the date before or equal to provided one', (date, dateToCompare, expectedResult) => { + expect(isBeforeOrEqual(date, dateToCompare)).toEqual(expectedResult); + }); + }); }); From 401418c04952387ada9213bc8018950569fa3213 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Dec 2021 20:14:26 +0100 Subject: [PATCH 11/49] Extended DateRangeSelector to allow updating its value via props after rendering --- src/utils/dates/DateRangeSelector.tsx | 12 +++++++++++- test/utils/dates/DateRangeSelector.test.tsx | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index b0a3c104..3e9d10fe 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { DropdownItem } from 'reactstrap'; import { DropdownBtn } from '../DropdownBtn'; +import { useEffectExceptFirstTime } from '../helpers/hooks'; import { DateInterval, DateRange, @@ -17,10 +18,11 @@ export interface DateRangeSelectorProps { disabled?: boolean; onDatesChange: (dateRange: DateRange) => void; defaultText: string; + updatable?: boolean; } export const DateRangeSelector = ( - { onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps, + { onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps, ) => { const initialIntervalIsRange = rangeIsInterval(initialDateRange); const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined); @@ -37,6 +39,14 @@ export const DateRangeSelector = ( onDatesChange(intervalToDateRange(dateInterval)); }; + updatable && useEffectExceptFirstTime(() => { + if (rangeIsInterval(initialDateRange)) { + updateInterval(initialDateRange); + } else if (initialDateRange) { + updateDateRange(initialDateRange); + } + }, [ initialDateRange ]); + return ( diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx index ef794918..aabd64fd 100644 --- a/test/utils/dates/DateRangeSelector.test.tsx +++ b/test/utils/dates/DateRangeSelector.test.tsx @@ -44,7 +44,7 @@ describe('', () => { [ 'last7Days' as DateInterval, 1 ], [ 'last30Days' as DateInterval, 1 ], [ 'last90Days' as DateInterval, 1 ], - [ 'last180days' as DateInterval, 1 ], + [ 'last180Days' as DateInterval, 1 ], [ 'last365Days' as DateInterval, 1 ], [{ startDate: new Date() }, 0 ], ])('sets proper element as active based on provided date range', (initialDateRange, expectedActiveIntervalItems) => { From 3745b297dbfd7b8d40790880141873c333b8f05d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Dec 2021 20:19:54 +0100 Subject: [PATCH 12/49] Updated visits components to support the doFallbackRange flag --- src/visits/OrphanVisits.tsx | 5 +++-- src/visits/ShortUrlVisits.tsx | 5 +++-- src/visits/TagVisits.tsx | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index e87a93fe..f30f5557 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -10,7 +10,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { - getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType) => void; + getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType, doFallbackRange?: boolean) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; } @@ -25,7 +25,8 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure selectedServer, }: OrphanVisitsProps) => { const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); - const loadVisits = (params: VisitsParams) => getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType); + const loadVisits = (params: VisitsParams, doFallbackRange?: boolean) => + getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doFallbackRange); return ( { - getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; + getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doFallbackRange?: boolean) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: Function; shortUrlDetail: ShortUrlDetail; @@ -35,7 +35,8 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( }: ShortUrlVisitsProps) => { const { shortCode } = params; const { domain } = parseQuery<{ domain?: string }>(search); - const loadVisits = (params: VisitsParams) => getShortUrlVisits(shortCode, { ...toApiParams(params), domain }); + const loadVisits = (params: VisitsParams, doFallbackRange?: boolean) => + getShortUrlVisits(shortCode, { ...toApiParams(params), domain }, doFallbackRange); const exportCsv = (visits: NormalizedVisit[]) => exportVisits( `short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`, visits, diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index d7619a0a..fefbaa7e 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -12,7 +12,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> { - getTagVisits: (tag: string, query?: ShlinkVisitsParams) => void; + getTagVisits: (tag: string, query?: ShlinkVisitsParams, doFallbackRange?: boolean) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; } @@ -27,7 +27,8 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor selectedServer, }: TagVisitsProps) => { const { tag } = params; - const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, toApiParams(params)); + const loadVisits = (params: ShlinkVisitsParams, doFallbackRange?: boolean) => + getTagVisits(tag, toApiParams(params), doFallbackRange); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits); return ( From 64ba346566af5a116b50a0f89d88fee2014dc1f8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Dec 2021 20:23:26 +0100 Subject: [PATCH 13/49] Updated VisitsStats components to react to the fallbackInterval --- src/visits/VisitsStats.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 4f4e3706..e136383e 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -1,5 +1,5 @@ import { isEmpty, propEq, values } from 'ramda'; -import { useState, useEffect, useMemo, FC } from 'react'; +import { useState, useEffect, useMemo, FC, useRef } from 'react'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons'; @@ -28,7 +28,7 @@ import { SortableBarChartCard } from './charts/SortableBarChartCard'; import './VisitsStats.scss'; export interface VisitsStatsProps { - getVisits: (params: VisitsParams) => void; + getVisits: (params: VisitsParams, doFallbackRange?: boolean) => void; visitsInfo: VisitsInfo; settings: Settings; selectedServer: SelectedServer; @@ -81,19 +81,22 @@ const VisitsStats: FC = ({ selectedServer, isOrphanVisits = false, }) => { - const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; + const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; + const [ initialInterval, setInitialInterval ] = useState( + fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days', + ); const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); const [ visitsFilter, setVisitsFilter ] = useState({}); const botsSupported = supportsBotVisits(selectedServer); + const isFirstLoad = useRef(true); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; }; - const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), @@ -121,8 +124,12 @@ const VisitsStats: FC = ({ useEffect(() => cancelGetVisits, []); useEffect(() => { - getVisits({ dateRange, filter: visitsFilter }); + getVisits({ dateRange, filter: visitsFilter }, isFirstLoad.current); + isFirstLoad.current = false; }, [ dateRange, visitsFilter ]); + useEffect(() => { + fallbackInterval && setInitialInterval(fallbackInterval); + }, [ fallbackInterval ]); const renderVisitsContent = () => { if (loadingLarge) { @@ -272,6 +279,7 @@ const VisitsStats: FC = ({
Date: Wed, 22 Dec 2021 20:34:56 +0100 Subject: [PATCH 14/49] Updated changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb6cea1..ce34677b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added +* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter". + + Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit. + + If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval. + * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. From e22856ff7467e8b33b6c90347fa482e9f998e9c3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 Dec 2021 10:38:02 +0100 Subject: [PATCH 15/49] Added logic in reducers to fallback to a different date interval if default one returns no visits --- src/visits/reducers/common.ts | 25 +++++++++-- src/visits/reducers/orphanVisits.ts | 21 +++++++-- src/visits/reducers/shortUrlVisits.ts | 21 ++++++--- src/visits/reducers/tagVisits.ts | 14 ++++-- test/visits/reducers/orphanVisits.test.ts | 43 ++++++++++++++++++- test/visits/reducers/shortUrlVisits.test.ts | 43 ++++++++++++++++++- test/visits/reducers/tagVisits.test.ts | 47 +++++++++++++++++++-- 7 files changed, 193 insertions(+), 21 deletions(-) diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 4e43445b..174b36df 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,9 +1,10 @@ import { flatten, prop, range, splitEvery } from 'ramda'; import { Action, Dispatch } from 'redux'; -import { ShlinkPaginator, ShlinkVisits } from '../../api/types'; +import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; import { Visit } from '../types'; import { parseApiError } from '../../api/utils'; import { ApiErrorAction } from '../../api/types/actions'; +import { dateToMatchingInterval } from '../../utils/dates/types'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -13,16 +14,19 @@ const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => cu const calcProgress = (total: number, current: number): number => current * 100 / total; type VisitsLoader = (page: number, itemsPerPage: number) => Promise; +type LastVisitLoader = () => Promise; interface ActionMap { start: string; large: string; finish: string; error: string; progress: string; + fallbackToInterval: string; } export const getVisitsWithLoader = async & { visits: Visit[] }>( visitsLoader: VisitsLoader, + lastVisitLoader: LastVisitLoader, extraFinishActionData: Partial, actionMap: ActionMap, dispatch: Dispatch, @@ -69,10 +73,25 @@ export const getVisitsWithLoader = async & { visits: V }; try { - const visits = await loadVisits(); + const [ visits, lastVisit ] = await Promise.all([ loadVisits(), lastVisitLoader() ]); - dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); + dispatch( + !visits.length && lastVisit + ? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) } + : { ...extraFinishActionData, visits, type: actionMap.finish }, + ); } catch (e: any) { dispatch({ type: actionMap.error, errorData: parseApiError(e) }); } }; + +export const lastVisitLoaderForLoader = ( + doFallbackRange: boolean, + loader: (params: ShlinkVisitsParams) => Promise, +): LastVisitLoader => { + if (!doFallbackRange) { + return async () => Promise.resolve(undefined); + } + + return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]); +}; diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 4a86c8e3..f212f323 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,5 +1,12 @@ import { Action, Dispatch } from 'redux'; -import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { + OrphanVisit, + OrphanVisitType, + Visit, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoadProgressChangedAction, +} from '../types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; @@ -7,7 +14,7 @@ import { ShlinkVisitsParams } from '../../api/types'; import { isOrphanVisit } from '../types/helpers'; import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; -import { getVisitsWithLoader } from './common'; +import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ @@ -17,6 +24,7 @@ export const GET_ORPHAN_VISITS = 'shlink/orphanVisits/GET_ORPHAN_VISITS'; export const GET_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE'; export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL'; export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHAN_VISITS_PROGRESS_CHANGED'; +export const GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL'; /* eslint-enable padding-line-between-statements */ export interface OrphanVisitsAction extends Action { @@ -26,6 +34,7 @@ export interface OrphanVisitsAction extends Action { type OrphanVisitsCombinedAction = OrphanVisitsAction & VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction & CreateVisitsAction & ApiErrorAction; @@ -41,10 +50,11 @@ const initialState: VisitsInfo = { export default buildReducer({ [GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_ORPHAN_VISITS]: (_, { visits, query }) => ({ ...initialState, visits, query }), + [GET_ORPHAN_VISITS]: (state, { visits, query }) => ({ ...state, visits, query, loading: false, error: false }), [GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), [CREATE_VISITS]: (state, { createdVisits }) => { const { visits, query = {} } = state; const { startDate, endDate } = query; @@ -62,6 +72,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( query: ShlinkVisitsParams = {}, orphanVisitsType?: OrphanVisitType, + doFallbackRange = false, ) => async (dispatch: Dispatch, getState: GetState) => { const { getOrphanVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage }) @@ -70,6 +81,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => return { ...result, data: visits }; }); + const lastVisitLoader = lastVisitLoaderForLoader(doFallbackRange, getOrphanVisits); const shouldCancel = () => getState().orphanVisits.cancelLoad; const extraFinishActionData: Partial = { query }; const actionMap = { @@ -78,9 +90,10 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => finish: GET_ORPHAN_VISITS, error: GET_ORPHAN_VISITS_ERROR, progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED, + fallbackToInterval: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, }; - return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); }; export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 81b26ca0..7d5daa17 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,6 +1,6 @@ import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { ShortUrlIdentifier } from '../../short-urls/data'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; @@ -8,7 +8,7 @@ import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; -import { getVisitsWithLoader } from './common'; +import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ @@ -18,6 +18,7 @@ export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS' export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED'; +export const GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL'; /* eslint-enable padding-line-between-statements */ export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} @@ -29,6 +30,7 @@ interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction & CreateVisitsAction & ApiErrorAction; @@ -46,16 +48,19 @@ const initialState: ShortUrlVisits = { export default buildReducer({ [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_SHORT_URL_VISITS]: (_, { visits, query, shortCode, domain }) => ({ - ...initialState, + [GET_SHORT_URL_VISITS]: (state, { visits, query, shortCode, domain }) => ({ + ...state, visits, shortCode, domain, query, + loading: false, + error: false, }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), [CREATE_VISITS]: (state, { createdVisits }) => { const { shortCode, domain, visits, query = {} } = state; const { startDate, endDate } = query; @@ -73,12 +78,17 @@ export default buildReducer({ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( shortCode: string, query: ShlinkVisitsParams = {}, + doFallbackRange = false, ) => async (dispatch: Dispatch, getState: GetState) => { const { getShortUrlVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits( shortCode, { ...query, page, itemsPerPage }, ); + const lastVisitLoader = lastVisitLoaderForLoader( + doFallbackRange, + async (params) => getShortUrlVisits(shortCode, { ...params, domain: query.domain }), + ); const shouldCancel = () => getState().shortUrlVisits.cancelLoad; const extraFinishActionData: Partial = { shortCode, query, domain: query.domain }; const actionMap = { @@ -87,9 +97,10 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) finish: GET_SHORT_URL_VISITS, error: GET_SHORT_URL_VISITS_ERROR, progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, + fallbackToInterval: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, }; - return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); }; export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index c628021f..ff96f90b 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,12 +1,12 @@ import { Action, Dispatch } from 'redux'; -import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { Visit, VisitsFallbackIntervalAction, 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 { isBetween } from '../../utils/helpers/date'; -import { getVisitsWithLoader } from './common'; +import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ @@ -16,6 +16,7 @@ export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS'; export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE'; export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED'; +export const GET_TAG_VISITS_FALLBACK_TO_INTERVAL = 'shlink/tagVisits/GET_TAG_VISITS_FALLBACK_TO_INTERVAL'; /* eslint-enable padding-line-between-statements */ export interface TagVisits extends VisitsInfo { @@ -30,6 +31,7 @@ export interface TagVisitsAction extends Action { type TagsVisitsCombinedAction = TagVisitsAction & VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction & CreateVisitsAction & ApiErrorAction; @@ -46,10 +48,11 @@ const initialState: TagVisits = { export default buildReducer({ [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_TAG_VISITS]: (_, { visits, tag, query }) => ({ ...initialState, visits, tag, query }), + [GET_TAG_VISITS]: (state, { visits, tag, query }) => ({ ...state, visits, tag, query, loading: false, error: false }), [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [GET_TAG_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), [CREATE_VISITS]: (state, { createdVisits }) => { const { tag, visits, query = {} } = state; const { startDate, endDate } = query; @@ -64,12 +67,14 @@ export default buildReducer({ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( tag: string, query: ShlinkVisitsParams = {}, + doFallbackRange = false, ) => async (dispatch: Dispatch, getState: GetState) => { const { getTagVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits( tag, { ...query, page, itemsPerPage }, ); + const lastVisitLoader = lastVisitLoaderForLoader(doFallbackRange, async (params) => getTagVisits(tag, params)); const shouldCancel = () => getState().tagVisits.cancelLoad; const extraFinishActionData: Partial = { tag, query }; const actionMap = { @@ -78,9 +83,10 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( finish: GET_TAG_VISITS, error: GET_TAG_VISITS_ERROR, progress: GET_TAG_VISITS_PROGRESS_CHANGED, + fallbackToInterval: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, }; - return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); }; export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL); diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 86296131..8b1e31ba 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -1,5 +1,5 @@ import { Mock } from 'ts-mockery'; -import { addDays, subDays } from 'date-fns'; +import { addDays, formatISO, subDays } from 'date-fns'; import reducer, { getOrphanVisits, cancelGetOrphanVisits, @@ -9,6 +9,7 @@ import reducer, { GET_ORPHAN_VISITS_LARGE, GET_ORPHAN_VISITS_CANCEL, GET_ORPHAN_VISITS_PROGRESS_CHANGED, + GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, } from '../../../src/visits/reducers/orphanVisits'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { rangeOf } from '../../../src/utils/utils'; @@ -17,6 +18,7 @@ import { ShlinkVisits } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; import { formatIsoDate } from '../../../src/utils/helpers/date'; +import { DateInterval } from '../../../src/utils/dates/types'; describe('orphanVisitsReducer', () => { const now = new Date(); @@ -116,6 +118,13 @@ describe('orphanVisitsReducer', () => { expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); + + it('returns fallbackInterval on GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => { + const fallbackInterval: DateInterval = 'last30Days'; + const state = reducer(undefined, { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + + expect(state).toEqual(expect.objectContaining({ fallbackInterval })); + }); }); describe('getOrphanVisits', () => { @@ -163,6 +172,38 @@ describe('orphanVisitsReducer', () => { expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} }); expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); }); + + it.each([ + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 5)) }) ], + { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + ], + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 200)) }) ], + { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' }, + ], + [[], expect.objectContaining({ type: GET_ORPHAN_VISITS }) ], + ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ + data, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + }); + const getShlinkOrphanVisits = jest.fn() + .mockResolvedValueOnce(buildVisitsResult()) + .mockResolvedValueOnce(buildVisitsResult(lastVisits)); + const ShlinkApiClient = Mock.of({ getOrphanVisits: getShlinkOrphanVisits }); + + await getOrphanVisits(() => ShlinkApiClient)({}, undefined, true)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); + expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2); + }); }); describe('cancelGetOrphanVisits', () => { diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index 8d45f7da..0c6d3433 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -1,5 +1,5 @@ import { Mock } from 'ts-mockery'; -import { addDays, subDays } from 'date-fns'; +import { addDays, formatISO, subDays } from 'date-fns'; import reducer, { getShortUrlVisits, cancelGetShortUrlVisits, @@ -9,6 +9,7 @@ import reducer, { GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_CANCEL, GET_SHORT_URL_VISITS_PROGRESS_CHANGED, + GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, ShortUrlVisits, } from '../../../src/visits/reducers/shortUrlVisits'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; @@ -18,6 +19,7 @@ import { ShlinkVisits } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; import { formatIsoDate } from '../../../src/utils/helpers/date'; +import { DateInterval } from '../../../src/utils/dates/types'; describe('shortUrlVisitsReducer', () => { const now = new Date(); @@ -137,6 +139,13 @@ describe('shortUrlVisitsReducer', () => { expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); + + it('returns fallbackInterval on GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL', () => { + const fallbackInterval: DateInterval = 'last30Days'; + const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + + expect(state).toEqual(expect.objectContaining({ fallbackInterval })); + }); }); describe('getShortUrlVisits', () => { @@ -209,6 +218,38 @@ describe('shortUrlVisitsReducer', () => { visits: [ ...visitsMocks, ...visitsMocks, ...visitsMocks ], })); }); + + it.each([ + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 5)) }) ], + { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + ], + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 200)) }) ], + { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' }, + ], + [[], expect.objectContaining({ type: GET_SHORT_URL_VISITS }) ], + ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ + data, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + }); + const getShlinkShortUrlVisits = jest.fn() + .mockResolvedValueOnce(buildVisitsResult()) + .mockResolvedValueOnce(buildVisitsResult(lastVisits)); + const ShlinkApiClient = Mock.of({ getShortUrlVisits: getShlinkShortUrlVisits }); + + await getShortUrlVisits(() => ShlinkApiClient)('abc123', {}, true)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); + expect(getShlinkShortUrlVisits).toHaveBeenCalledTimes(2); + }); }); describe('cancelGetShortUrlVisits', () => { diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index f026e5bb..d37f5043 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -1,5 +1,5 @@ import { Mock } from 'ts-mockery'; -import { addDays, subDays } from 'date-fns'; +import { addDays, formatISO, subDays } from 'date-fns'; import reducer, { getTagVisits, cancelGetTagVisits, @@ -9,6 +9,7 @@ import reducer, { GET_TAG_VISITS_LARGE, GET_TAG_VISITS_CANCEL, GET_TAG_VISITS_PROGRESS_CHANGED, + GET_TAG_VISITS_FALLBACK_TO_INTERVAL, TagVisits, } from '../../../src/visits/reducers/tagVisits'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; @@ -18,6 +19,7 @@ import { ShlinkVisits } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; import { formatIsoDate } from '../../../src/utils/helpers/date'; +import { DateInterval } from '../../../src/utils/dates/types'; describe('tagVisitsReducer', () => { const now = new Date(); @@ -137,6 +139,13 @@ describe('tagVisitsReducer', () => { expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); + + it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => { + const fallbackInterval: DateInterval = 'last30Days'; + const state = reducer(undefined, { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + + expect(state).toEqual(expect.objectContaining({ fallbackInterval })); + }); }); describe('getTagVisits', () => { @@ -149,8 +158,9 @@ describe('tagVisitsReducer', () => { const getState = () => Mock.of({ tagVisits: { cancelLoad: false }, }); + const tag = 'foo'; - beforeEach(jest.resetAllMocks); + beforeEach(jest.clearAllMocks); it('dispatches start and error when promise is rejected', async () => { const ShlinkApiClient = buildApiClientMock(Promise.reject({})); @@ -168,7 +178,6 @@ describe('tagVisitsReducer', () => { [{}], ])('dispatches start and success when promise is resolved', async (query) => { const visits = visitsMocks; - const tag = 'foo'; const ShlinkApiClient = buildApiClientMock(Promise.resolve({ data: visitsMocks, pagination: { @@ -185,6 +194,38 @@ describe('tagVisitsReducer', () => { expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag, query: query ?? {} }); expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); }); + + it.each([ + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 20)) }) ], + { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + ], + [ + [ Mock.of({ date: formatISO(subDays(new Date(), 100)) }) ], + { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last180Days' }, + ], + [[], expect.objectContaining({ type: GET_TAG_VISITS }) ], + ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ + data, + pagination: { + currentPage: 1, + pagesCount: 1, + totalItems: 1, + }, + }); + const getShlinkTagVisits = jest.fn() + .mockResolvedValueOnce(buildVisitsResult()) + .mockResolvedValueOnce(buildVisitsResult(lastVisits)); + const ShlinkApiClient = Mock.of({ getTagVisits: getShlinkTagVisits }); + + await getTagVisits(() => ShlinkApiClient)(tag, {}, true)(dispatchMock, getState); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); + expect(getShlinkTagVisits).toHaveBeenCalledTimes(2); + }); }); describe('cancelGetTagVisits', () => { From c517c0521c9420780ff17839d016a3180ce55d2b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 Dec 2021 10:51:13 +0100 Subject: [PATCH 16/49] Renamed doFallbackRange to doIntervalFallback to make it more descriptive --- CHANGELOG.md | 3 +++ src/utils/dates/DateRangeSelector.tsx | 9 ++++----- src/visits/OrphanVisits.tsx | 10 +++++++--- src/visits/ShortUrlVisits.tsx | 6 +++--- src/visits/TagVisits.tsx | 6 +++--- src/visits/VisitsStats.tsx | 2 +- src/visits/reducers/common.ts | 4 ++-- src/visits/reducers/orphanVisits.ts | 4 ++-- src/visits/reducers/shortUrlVisits.ts | 4 ++-- src/visits/reducers/tagVisits.ts | 4 ++-- 10 files changed, 29 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce34677b..e29a5871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. * [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `-` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0 +* Fixed typo in identifier for "Last 180 days" interval. + + If that was your default interval, you will see now "All visits" is selected instead. You will need to go to settings page and change it again to "Last 180 days". ### Deprecated * *Nothing* diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index 3e9d10fe..c9d7dd1a 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -40,11 +40,10 @@ export const DateRangeSelector = ( }; updatable && useEffectExceptFirstTime(() => { - if (rangeIsInterval(initialDateRange)) { - updateInterval(initialDateRange); - } else if (initialDateRange) { - updateDateRange(initialDateRange); - } + const isDateInterval = rangeIsInterval(initialDateRange); + + isDateInterval && updateInterval(initialDateRange); + initialDateRange && !isDateInterval && updateDateRange(initialDateRange); }, [ initialDateRange ]); return ( diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index f30f5557..af4dc7a4 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -10,7 +10,11 @@ import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { - getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType, doFallbackRange?: boolean) => void; + getOrphanVisits: ( + params?: ShlinkVisitsParams, + orphanVisitsType?: OrphanVisitType, + doIntervalFallback?: boolean, + ) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; } @@ -25,8 +29,8 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure selectedServer, }: OrphanVisitsProps) => { const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); - const loadVisits = (params: VisitsParams, doFallbackRange?: boolean) => - getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doFallbackRange); + const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => + getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doIntervalFallback); return ( { - getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doFallbackRange?: boolean) => void; + getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: Function; shortUrlDetail: ShortUrlDetail; @@ -35,8 +35,8 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( }: ShortUrlVisitsProps) => { const { shortCode } = params; const { domain } = parseQuery<{ domain?: string }>(search); - const loadVisits = (params: VisitsParams, doFallbackRange?: boolean) => - getShortUrlVisits(shortCode, { ...toApiParams(params), domain }, doFallbackRange); + const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => + getShortUrlVisits(shortCode, { ...toApiParams(params), domain }, doIntervalFallback); const exportCsv = (visits: NormalizedVisit[]) => exportVisits( `short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`, visits, diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index fefbaa7e..702bd837 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -12,7 +12,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> { - getTagVisits: (tag: string, query?: ShlinkVisitsParams, doFallbackRange?: boolean) => void; + getTagVisits: (tag: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; } @@ -27,8 +27,8 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor selectedServer, }: TagVisitsProps) => { const { tag } = params; - const loadVisits = (params: ShlinkVisitsParams, doFallbackRange?: boolean) => - getTagVisits(tag, toApiParams(params), doFallbackRange); + const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => + getTagVisits(tag, toApiParams(params), doIntervalFallback); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits); return ( diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index e136383e..8818ada7 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -28,7 +28,7 @@ import { SortableBarChartCard } from './charts/SortableBarChartCard'; import './VisitsStats.scss'; export interface VisitsStatsProps { - getVisits: (params: VisitsParams, doFallbackRange?: boolean) => void; + getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; visitsInfo: VisitsInfo; settings: Settings; selectedServer: SelectedServer; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 174b36df..0d9ecff6 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -86,10 +86,10 @@ export const getVisitsWithLoader = async & { visits: V }; export const lastVisitLoaderForLoader = ( - doFallbackRange: boolean, + doIntervalFallback: boolean, loader: (params: ShlinkVisitsParams) => Promise, ): LastVisitLoader => { - if (!doFallbackRange) { + if (!doIntervalFallback) { return async () => Promise.resolve(undefined); } diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index f212f323..bcb06d8a 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -72,7 +72,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( query: ShlinkVisitsParams = {}, orphanVisitsType?: OrphanVisitType, - doFallbackRange = false, + doIntervalFallback = false, ) => async (dispatch: Dispatch, getState: GetState) => { const { getOrphanVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage }) @@ -81,7 +81,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => return { ...result, data: visits }; }); - const lastVisitLoader = lastVisitLoaderForLoader(doFallbackRange, getOrphanVisits); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getOrphanVisits); const shouldCancel = () => getState().orphanVisits.cancelLoad; const extraFinishActionData: Partial = { query }; const actionMap = { diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 7d5daa17..9b5cd352 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -78,7 +78,7 @@ export default buildReducer({ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( shortCode: string, query: ShlinkVisitsParams = {}, - doFallbackRange = false, + doIntervalFallback = false, ) => async (dispatch: Dispatch, getState: GetState) => { const { getShortUrlVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits( @@ -86,7 +86,7 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) { ...query, page, itemsPerPage }, ); const lastVisitLoader = lastVisitLoaderForLoader( - doFallbackRange, + doIntervalFallback, async (params) => getShortUrlVisits(shortCode, { ...params, domain: query.domain }), ); const shouldCancel = () => getState().shortUrlVisits.cancelLoad; diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index ff96f90b..272bd241 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -67,14 +67,14 @@ export default buildReducer({ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( tag: string, query: ShlinkVisitsParams = {}, - doFallbackRange = false, + doIntervalFallback = false, ) => async (dispatch: Dispatch, getState: GetState) => { const { getTagVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits( tag, { ...query, page, itemsPerPage }, ); - const lastVisitLoader = lastVisitLoaderForLoader(doFallbackRange, async (params) => getTagVisits(tag, params)); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getTagVisits(tag, params)); const shouldCancel = () => getState().tagVisits.cancelLoad; const extraFinishActionData: Partial = { tag, query }; const actionMap = { From 5598fe0f530e37ddae556f90ffd4e8b4f8a87476 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 Dec 2021 17:53:06 +0100 Subject: [PATCH 17/49] Created new settings card for tags-related options --- CHANGELOG.md | 3 -- src/container/index.ts | 5 ++- src/container/store.ts | 7 ++-- src/index.tsx | 4 +-- src/settings/Settings.tsx | 7 ++-- src/settings/Tags.tsx | 25 ++++++++++++++ src/settings/UserInterface.tsx | 11 ------ src/settings/helpers/index.ts | 17 +++++++++ src/settings/reducers/settings.ts | 13 ++++++- src/settings/services/provideServices.ts | 8 ++++- src/tags/TagsList.tsx | 2 +- test/settings/Settings.test.tsx | 4 +-- test/settings/Tags.test.tsx | 44 ++++++++++++++++++++++++ test/settings/UserInterface.test.tsx | 29 +--------------- 14 files changed, 121 insertions(+), 58 deletions(-) create mode 100644 src/settings/Tags.tsx create mode 100644 src/settings/helpers/index.ts create mode 100644 test/settings/Tags.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e29a5871..ce34677b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. * [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `-` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0 -* Fixed typo in identifier for "Last 180 days" interval. - - If that was your default interval, you will see now "All visits" is selected instead. You will need to go to settings page and change it again to "Last 180 days". ### Deprecated * *Nothing* diff --git a/src/container/index.ts b/src/container/index.ts index 1fbf7bd3..aedf0ece 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -18,7 +18,8 @@ import { ConnectDecorator } from './types'; type LazyActionMap = Record; const bottle = new Bottle(); -const { container } = bottle; + +export const { container } = bottle; const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => (container[serviceName] as T)(...args) as K; @@ -44,5 +45,3 @@ provideUtilsServices(bottle); provideMercureServices(bottle); provideSettingsServices(bottle, connect); provideDomainsServices(bottle, connect); - -export default container; diff --git a/src/container/store.ts b/src/container/store.ts index 3196d6c2..e99dbd8f 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk'; import { applyMiddleware, compose, createStore } from 'redux'; import { save, load, RLSOptions } from 'redux-localstorage-simple'; import reducers from '../reducers'; +import { migrateDeprecatedSettings } from '../settings/helpers'; +import { ShlinkState } from './types'; const isProduction = process.env.NODE_ENV !== 'production'; const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; @@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = { namespaceSeparator: '.', debounce: 300, }; +const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); -const store = createStore(reducers, load(localStorageConfig), composeEnhancers( +export const store = createStore(reducers, preloadedState, composeEnhancers( applyMiddleware(save(localStorageConfig), ReduxThunk), )); - -export default store; diff --git a/src/index.tsx b/src/index.tsx index cc47e20c..eb2e31dd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,8 @@ import { render } from 'react-dom'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { homepage } from '../package.json'; -import container from './container'; -import store from './container/store'; +import { container } from './container'; +import { store } from './container/store'; import { fixLeafletIcons } from './utils/helpers/leaflet'; import { register as registerServiceWorker } from './serviceWorkerRegistration'; import 'react-datepicker/dist/react-datepicker.css'; diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index fc8ea340..e9d3369f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -7,7 +7,7 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => ( {items.map((child, index) => ( {child.map((subChild, subIndex) => ( -
+
{subChild}
))} @@ -16,12 +16,13 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => ( ); -const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => ( +const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC, Tags: 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/Tags.tsx b/src/settings/Tags.tsx new file mode 100644 index 00000000..47d52260 --- /dev/null +++ b/src/settings/Tags.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { FormGroup } from 'reactstrap'; +import { SimpleCard } from '../utils/SimpleCard'; +import { TagsModeDropdown } from '../tags/TagsModeDropdown'; +import { capitalize } from '../utils/utils'; +import { Settings, TagsSettings } from './reducers/settings'; + +interface TagsProps { + settings: Settings; + setTagsSettings: (settings: TagsSettings) => void; +} + +export const Tags: FC = ({ settings: { tags }, setTagsSettings }) => ( + + + + capitalize(tagsMode)} + onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })} + /> + Tags will be displayed as {tags?.defaultMode ?? 'cards'}. + + +); diff --git a/src/settings/UserInterface.tsx b/src/settings/UserInterface.tsx index fbd41a8c..e2d74a5d 100644 --- a/src/settings/UserInterface.tsx +++ b/src/settings/UserInterface.tsx @@ -5,8 +5,6 @@ import { FormGroup } from 'reactstrap'; import { SimpleCard } from '../utils/SimpleCard'; import ToggleSwitch from '../utils/ToggleSwitch'; import { changeThemeInMarkup, Theme } from '../utils/theme'; -import { TagsModeDropdown } from '../tags/TagsModeDropdown'; -import { capitalize } from '../utils/utils'; import { Settings, UiSettings } from './reducers/settings'; import './UserInterface.scss'; @@ -31,14 +29,5 @@ export const UserInterface: FC = ({ settings: { ui }, setUiS Use dark theme. - - - capitalize(tagsMode)} - onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })} - /> - Tags will be displayed as {ui?.tagsMode ?? 'cards'}. - ); diff --git a/src/settings/helpers/index.ts b/src/settings/helpers/index.ts new file mode 100644 index 00000000..a405e827 --- /dev/null +++ b/src/settings/helpers/index.ts @@ -0,0 +1,17 @@ +import { ShlinkState } from '../../container/types'; + +export const migrateDeprecatedSettings = (state: ShlinkState): ShlinkState => { + // The "last180Days" interval had a typo, with a lowercase d + if ((state.settings.visits?.defaultInterval as any) === 'last180days') { + state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days'); + } + + // The "tags display mode" option has been moved from "ui" to "tags" + state.settings.tags = { + ...state.settings.tags, + defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode, + }; + state.settings.ui && delete (state.settings.ui as any).tagsMode; + + return state; +}; diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 6b0079d2..ee88cdc4 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -4,6 +4,7 @@ import { buildReducer } from '../../utils/helpers/redux'; import { RecursivePartial } from '../../utils/utils'; import { Theme } from '../../utils/theme'; import { DateInterval } from '../../utils/dates/types'; +import { TagsOrder } from '../../tags/data/TagsListChildrenProps'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; @@ -29,18 +30,23 @@ export type TagsMode = 'cards' | 'list'; export interface UiSettings { theme: Theme; - tagsMode?: TagsMode; } export interface VisitsSettings { defaultInterval: DateInterval; } +export interface TagsSettings { + defaultOrdering?: TagsOrder; + defaultMode?: TagsMode; +} + export interface Settings { realTimeUpdates: RealTimeUpdatesSettings; shortUrlCreation?: ShortUrlCreationSettings; ui?: UiSettings; visits?: VisitsSettings; + tags?: TagsSettings; } const initialState: Settings = { @@ -90,3 +96,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi type: SET_SETTINGS, visits: settings, }); + +export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({ + type: SET_SETTINGS, + tags: settings, +}); diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts index 52652154..93d82584 100644 --- a/src/settings/services/provideServices.ts +++ b/src/settings/services/provideServices.ts @@ -4,6 +4,7 @@ import Settings from '../Settings'; import { setRealTimeUpdatesInterval, setShortUrlCreationSettings, + setTagsSettings, setUiSettings, setVisitsSettings, toggleRealTimeUpdates, @@ -13,10 +14,11 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ import { ShortUrlCreation } from '../ShortUrlCreation'; import { UserInterface } from '../UserInterface'; import { Visits } from '../Visits'; +import { Tags } from '../Tags'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components - bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits'); + bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits', 'Tags'); bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); @@ -35,12 +37,16 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('Visits', () => Visits); bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ])); + bottle.serviceFactory('Tags', () => Tags); + bottle.decorator('Tags', connect([ 'settings' ], [ 'setTagsSettings' ])); + // Actions bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings); bottle.serviceFactory('setUiSettings', () => setUiSettings); bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings); + bottle.serviceFactory('setTagsSettings', () => setTagsSettings); }; export default provideServices; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index 5254dd2c..7aff73af 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -28,7 +28,7 @@ export interface TagsListProps { const TagsList = (TagsCards: FC, TagsTable: FC) => boundToMercureHub(( { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps, ) => { - const [ mode, setMode ] = useState(settings.ui?.tagsMode ?? 'cards'); + const [ mode, setMode ] = useState(settings.tags?.defaultMode ?? 'cards'); const [ order, setOrder ] = useState({}); const resolveSortedTags = pipe( () => tagsList.filteredTags.map((tag): NormalizedTag => ({ diff --git a/test/settings/Settings.test.tsx b/test/settings/Settings.test.tsx index 1f34137d..e8f3560c 100644 --- a/test/settings/Settings.test.tsx +++ b/test/settings/Settings.test.tsx @@ -4,7 +4,7 @@ import NoMenuLayout from '../../src/common/NoMenuLayout'; describe('', () => { const Component = () => null; - const Settings = createSettings(Component, Component, Component, Component); + const Settings = createSettings(Component, Component, Component, Component, Component); it('renders a no-menu layout with the expected settings sections', () => { const wrapper = shallow(); @@ -13,6 +13,6 @@ describe('', () => { expect(layout).toHaveLength(1); expect(sections).toHaveLength(1); - expect((sections.prop('items') as any[]).flat()).toHaveLength(4); + expect((sections.prop('items') as any[]).flat()).toHaveLength(5); }); }); diff --git a/test/settings/Tags.test.tsx b/test/settings/Tags.test.tsx new file mode 100644 index 00000000..b9084fc9 --- /dev/null +++ b/test/settings/Tags.test.tsx @@ -0,0 +1,44 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { Settings, TagsMode, TagsSettings } from '../../src/settings/reducers/settings'; +import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; +import { Tags } from '../../src/settings/Tags'; + +describe('', () => { + let wrapper: ShallowWrapper; + const setTagsSettings = jest.fn(); + const createWrapper = (tags?: TagsSettings) => { + wrapper = shallow(({ tags })} setTagsSettings={setTagsSettings} />); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it.each([ + [ undefined, 'cards' ], + [{}, 'cards' ], + [{ defaultMode: 'cards' as TagsMode }, 'cards' ], + [{ defaultMode: 'list' as TagsMode }, 'list' ], + ])('shows expected tags displaying mode', (tags, expectedMode) => { + const wrapper = createWrapper(tags); + const dropdown = wrapper.find(TagsModeDropdown); + const small = wrapper.find('small'); + + expect(dropdown.prop('mode')).toEqual(expectedMode); + expect(small.html()).toContain(`Tags will be displayed as ${expectedMode}.`); + }); + + it.each([ + [ 'cards' as TagsMode ], + [ 'list' as TagsMode ], + ])('invokes setTagsSettings when tags mode changes', (defaultMode) => { + const wrapper = createWrapper(); + const dropdown = wrapper.find(TagsModeDropdown); + + expect(setTagsSettings).not.toHaveBeenCalled(); + dropdown.simulate('change', defaultMode); + expect(setTagsSettings).toHaveBeenCalledWith({ defaultMode }); + }); +}); diff --git a/test/settings/UserInterface.test.tsx b/test/settings/UserInterface.test.tsx index 3b205c0e..55abf685 100644 --- a/test/settings/UserInterface.test.tsx +++ b/test/settings/UserInterface.test.tsx @@ -2,11 +2,10 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Settings, TagsMode, UiSettings } from '../../src/settings/reducers/settings'; +import { Settings, UiSettings } from '../../src/settings/reducers/settings'; import { UserInterface } from '../../src/settings/UserInterface'; import ToggleSwitch from '../../src/utils/ToggleSwitch'; import { Theme } from '../../src/utils/theme'; -import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; describe('', () => { let wrapper: ShallowWrapper; @@ -53,30 +52,4 @@ describe('', () => { toggle.simulate('change', checked); expect(setUiSettings).toHaveBeenCalledWith({ theme }); }); - - it.each([ - [ undefined, 'cards' ], - [{ theme: 'light' as Theme }, 'cards' ], - [{ theme: 'light' as Theme, tagsMode: 'cards' as TagsMode }, 'cards' ], - [{ theme: 'light' as Theme, tagsMode: 'list' as TagsMode }, 'list' ], - ])('shows expected tags displaying mode', (ui, expectedMode) => { - const wrapper = createWrapper(ui); - const dropdown = wrapper.find(TagsModeDropdown); - const small = wrapper.find('small'); - - expect(dropdown.prop('mode')).toEqual(expectedMode); - expect(small.html()).toContain(`Tags will be displayed as ${expectedMode}.`); - }); - - it.each([ - [ 'cards' as TagsMode ], - [ 'list' as TagsMode ], - ])('invokes setUiSettings when tags mode changes', (tagsMode) => { - const wrapper = createWrapper(); - const dropdown = wrapper.find(TagsModeDropdown); - - expect(setUiSettings).not.toHaveBeenCalled(); - dropdown.simulate('change', tagsMode); - expect(setUiSettings).toHaveBeenCalledWith({ theme: 'light', tagsMode }); - }); }); From e954a860bf650c2d6d626b0c327431239ef1e140 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 23 Dec 2021 17:59:18 +0100 Subject: [PATCH 18/49] Added test for migrateDeprecatedSettings function --- test/settings/helpers/index.test.ts | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/settings/helpers/index.test.ts diff --git a/test/settings/helpers/index.test.ts b/test/settings/helpers/index.test.ts new file mode 100644 index 00000000..0a3b349e --- /dev/null +++ b/test/settings/helpers/index.test.ts @@ -0,0 +1,31 @@ +import { Mock } from 'ts-mockery'; +import { migrateDeprecatedSettings } from '../../../src/settings/helpers'; +import { ShlinkState } from '../../../src/container/types'; + +describe('settings-helpers', () => { + describe('migrateDeprecatedSettings', () => { + it('updates settings as expected', () => { + const state = Mock.of({ + settings: { + visits: { + defaultInterval: 'last180days' as any, + }, + ui: { + tagsMode: 'list', + } as any, + }, + }); + + expect(migrateDeprecatedSettings(state)).toEqual(expect.objectContaining({ + settings: expect.objectContaining({ + visits: { + defaultInterval: 'last180Days', + }, + tags: { + defaultMode: 'list', + }, + }), + })); + }); + }); +}); From d8442e435d48c9d7e076191b9811113b021cc89e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 11:05:22 +0100 Subject: [PATCH 19/49] Added option to customize ordering in tags list --- src/settings/Tags.tsx | 12 +++++- src/settings/reducers/settings.ts | 6 +++ src/short-urls/ShortUrlsList.tsx | 6 +-- .../reducers/shortUrlsListParams.ts | 4 +- src/tags/TagsList.tsx | 2 +- test/settings/Tags.test.tsx | 37 +++++++++++++++++++ 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/settings/Tags.tsx b/src/settings/Tags.tsx index 47d52260..63bc4aab 100644 --- a/src/settings/Tags.tsx +++ b/src/settings/Tags.tsx @@ -3,6 +3,8 @@ import { FormGroup } from 'reactstrap'; import { SimpleCard } from '../utils/SimpleCard'; import { TagsModeDropdown } from '../tags/TagsModeDropdown'; import { capitalize } from '../utils/utils'; +import SortingDropdown from '../utils/SortingDropdown'; +import { SORTABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; import { Settings, TagsSettings } from './reducers/settings'; interface TagsProps { @@ -12,7 +14,7 @@ interface TagsProps { export const Tags: FC = ({ settings: { tags }, setTagsSettings }) => ( - + = ({ settings: { tags }, setTagsSettings }) => /> Tags will be displayed as {tags?.defaultMode ?? 'cards'}. + + + setTagsSettings({ ...tags, defaultOrdering: { field, dir } })} + /> + ); diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index ee88cdc4..2a7e0fcc 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -5,6 +5,7 @@ import { RecursivePartial } from '../../utils/utils'; import { Theme } from '../../utils/theme'; import { DateInterval } from '../../utils/dates/types'; import { TagsOrder } from '../../tags/data/TagsListChildrenProps'; +import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; @@ -41,9 +42,14 @@ export interface TagsSettings { defaultMode?: TagsMode; } +export interface ShortUrlListSettings { + defaultOrdering?: ShortUrlsOrder; +} + export interface Settings { realTimeUpdates: RealTimeUpdatesSettings; shortUrlCreation?: ShortUrlCreationSettings; + shortUrlList?: ShortUrlListSettings; ui?: UiSettings; visits?: VisitsSettings; tags?: TagsSettings; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index b5fd5250..f7198a44 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -3,14 +3,14 @@ import { FC, useEffect, useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; import SortingDropdown from '../utils/SortingDropdown'; -import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering'; +import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { getServerId, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; +import { OrderableFields, ShortUrlsListParams, ShortUrlsOrder, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; @@ -23,8 +23,6 @@ interface ShortUrlsListProps extends RouteComponentProps void; } -type ShortUrlsOrder = Order; - const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) => boundToMercureHub(({ listShortUrls, resetShortUrlParams, diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts index c05d0ccc..cefc4814 100644 --- a/src/short-urls/reducers/shortUrlsListParams.ts +++ b/src/short-urls/reducers/shortUrlsListParams.ts @@ -1,5 +1,5 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; -import { OrderDir } from '../../utils/helpers/ordering'; +import { Order, OrderDir } from '../../utils/helpers/ordering'; import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList'; export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; @@ -14,6 +14,8 @@ export const SORTABLE_FIELDS = { export type OrderableFields = keyof typeof SORTABLE_FIELDS; +export type ShortUrlsOrder = Order; + export type OrderBy = Partial>; export interface ShortUrlsListParams { diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index 7aff73af..63832b53 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -29,7 +29,7 @@ const TagsList = (TagsCards: FC, TagsTable: FC { const [ mode, setMode ] = useState(settings.tags?.defaultMode ?? 'cards'); - const [ order, setOrder ] = useState({}); + const [ order, setOrder ] = useState(settings.tags?.defaultOrdering ?? {}); const resolveSortedTags = pipe( () => tagsList.filteredTags.map((tag): NormalizedTag => ({ tag, diff --git a/test/settings/Tags.test.tsx b/test/settings/Tags.test.tsx index b9084fc9..8e5440a4 100644 --- a/test/settings/Tags.test.tsx +++ b/test/settings/Tags.test.tsx @@ -1,8 +1,11 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; +import { FormGroup } from 'reactstrap'; import { Settings, TagsMode, TagsSettings } from '../../src/settings/reducers/settings'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import { Tags } from '../../src/settings/Tags'; +import SortingDropdown from '../../src/utils/SortingDropdown'; +import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps'; describe('', () => { let wrapper: ShallowWrapper; @@ -16,6 +19,13 @@ describe('', () => { afterEach(() => wrapper?.unmount()); afterEach(jest.clearAllMocks); + it('renders expected amount of groups', () => { + const wrapper = createWrapper(); + const groups = wrapper.find(FormGroup); + + expect(groups).toHaveLength(2); + }); + it.each([ [ undefined, 'cards' ], [{}, 'cards' ], @@ -41,4 +51,31 @@ describe('', () => { dropdown.simulate('change', defaultMode); expect(setTagsSettings).toHaveBeenCalledWith({ defaultMode }); }); + + it.each([ + [ undefined, {}], + [{}, {}], + [{ defaultOrdering: {} }, {}], + [{ defaultOrdering: { field: 'tag', dir: 'DESC' } as TagsOrder }, { field: 'tag', dir: 'DESC' }], + [{ defaultOrdering: { field: 'visits', dir: 'ASC' } as TagsOrder }, { field: 'visits', dir: 'ASC' }], + ])('shows expected ordering', (tags, expectedOrder) => { + const wrapper = createWrapper(tags); + const dropdown = wrapper.find(SortingDropdown); + + expect(dropdown.prop('order')).toEqual(expectedOrder); + }); + + it.each([ + [ undefined, undefined ], + [ 'tag', 'ASC' ], + [ 'visits', undefined ], + [ 'shortUrls', 'DESC' ], + ])('invokes setTagsSettings when ordering changes', (field, dir) => { + const wrapper = createWrapper(); + const dropdown = wrapper.find(SortingDropdown); + + expect(setTagsSettings).not.toHaveBeenCalled(); + dropdown.simulate('change', field, dir); + expect(setTagsSettings).toHaveBeenCalledWith({ defaultOrdering: { field, dir } }); + }); }); From 57075c581d8befe1fc19ac0a00283e566ac2b729 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 13:14:13 +0100 Subject: [PATCH 20/49] Updated Short URLs list so that it allows setting default orderBy from settings --- src/api/services/ShlinkApiClient.ts | 7 ++- src/api/types/index.ts | 4 +- src/servers/Overview.tsx | 2 +- src/settings/reducers/settings.ts | 8 ++++ src/short-urls/ShortUrlsList.tsx | 35 +++++++-------- src/short-urls/reducers/shortUrlsList.ts | 3 +- .../reducers/shortUrlsListParams.ts | 7 +-- src/short-urls/services/provideServices.ts | 2 +- test/api/services/ShlinkApiClient.test.ts | 8 ++-- test/settings/reducers/settings.test.ts | 13 +++++- test/short-urls/ShortUrlsList.test.tsx | 43 +++++++------------ .../reducers/shortUrlsListParams.test.ts | 6 +-- 12 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 192bad0f..330bfb55 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -24,12 +24,11 @@ const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/re const rejectNilProps = reject(isNil); const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const { orderBy = {}, ...rest } = params; - const [ firstKey ] = Object.keys(orderBy); - const [ firstValue ] = Object.values(orderBy); + const { field, dir } = orderBy; - return !firstValue ? rest : { + return !dir ? rest : { ...rest, - orderBy: `${firstKey}-${firstValue}`, + orderBy: `${field}-${dir}`, }; }; diff --git a/src/api/types/index.ts b/src/api/types/index.ts index cce4751c..af833bb2 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -1,7 +1,7 @@ import { Visit } from '../../visits/types'; import { OptionalString } from '../../utils/utils'; import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; -import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams'; +import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; export interface ShlinkShortUrlsResponse { data: ShortUrl[]; @@ -94,7 +94,7 @@ export interface ShlinkShortUrlsListParams { searchTerm?: string; startDate?: string; endDate?: string; - orderBy?: OrderBy; + orderBy?: ShortUrlsOrder; } export interface ShlinkShortUrlsListNormalizedParams extends Omit { diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 9b99cc6f..a68d3e17 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -44,7 +44,7 @@ export const Overview = ( const history = useHistory(); useEffect(() => { - listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } }); + listShortUrls({ itemsPerPage: 5, orderBy: { field: 'dateCreated', dir: 'DESC' } }); listTags(); loadVisitsOverview(); }, []); diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 2a7e0fcc..fb2932d6 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -9,6 +9,11 @@ import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; +export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = { + field: 'dateCreated', + dir: 'DESC', +}; + /** * Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as * optional, as old instances of the app will load partial objects from local storage until it is saved again. @@ -68,6 +73,9 @@ const initialState: Settings = { visits: { defaultInterval: 'last30Days', }, + shortUrlList: { + defaultOrdering: DEFAULT_SHORT_URLS_ORDERING, + }, }; type SettingsAction = Action & Settings; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index f7198a44..fe0623e7 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,4 +1,4 @@ -import { head, keys, pipe, values } from 'ramda'; +import { pipe } from 'ramda'; import { FC, useEffect, useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; @@ -9,6 +9,7 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; +import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { OrderableFields, ShortUrlsListParams, ShortUrlsOrder, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; @@ -18,9 +19,10 @@ import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; interface ShortUrlsListProps extends RouteComponentProps { selectedServer: SelectedServer; shortUrlsList: ShortUrlsListState; - listShortUrls: (params: ShortUrlsListParams) => void; + listShortUrls: (params: ShlinkShortUrlsListParams) => void; shortUrlsListParams: ShortUrlsListParams; resetShortUrlParams: () => void; + settings: Settings; } const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) => boundToMercureHub(({ @@ -32,24 +34,17 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = history, shortUrlsList, selectedServer, + settings, }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); const { orderBy } = shortUrlsListParams; - const [ order, setOrder ] = useState({ - field: orderBy && (head(keys(orderBy)) as OrderableFields), - dir: orderBy && head(values(orderBy)), - }); + const initialOrderBy = orderBy ?? settings.shortUrlList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; + const [ order, setOrder ] = useState(initialOrderBy); const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); const { pagination } = shortUrlsList?.shortUrls ?? {}; - const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls( - { ...shortUrlsListParams, ...extraParams }, - ); - const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => { - setOrder({ field, dir }); - refreshList({ orderBy: field ? { [field]: dir } : undefined }); - }; + const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => setOrder({ field, dir }); const orderByColumn = (field: OrderableFields) => () => handleOrderBy(field, determineOrderDir(field, order.field, order.dir)); const renderOrderIcon = (field: OrderableFields) => ; @@ -60,10 +55,16 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = useEffect(() => resetShortUrlParams, []); useEffect(() => { - refreshList( - { page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate }, - ); - }, [ match.params.page, search, selectedTags, startDate, endDate ]); + listShortUrls({ + page: match.params.page, + searchTerm: search, + tags: selectedTags, + itemsPerPage: undefined, + startDate, + endDate, + orderBy: order, + }); + }, [ match.params.page, search, selectedTags, startDate, endDate, order ]); return ( <> diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 72290bea..e50d931c 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -7,7 +7,6 @@ import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; -import { ShortUrlsListParams } from './shortUrlsListParams'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; @@ -25,7 +24,7 @@ export interface ShortUrlsList { export interface ListShortUrlsAction extends Action { shortUrls: ShlinkShortUrlsResponse; - params: ShortUrlsListParams; + params: ShlinkShortUrlsListParams; } export type ListShortUrlsCombinedAction = ( diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts index cefc4814..f2c2b0bc 100644 --- a/src/short-urls/reducers/shortUrlsListParams.ts +++ b/src/short-urls/reducers/shortUrlsListParams.ts @@ -1,5 +1,5 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; -import { Order, OrderDir } from '../../utils/helpers/ordering'; +import { Order } from '../../utils/helpers/ordering'; import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList'; export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; @@ -16,17 +16,14 @@ export type OrderableFields = keyof typeof SORTABLE_FIELDS; export type ShortUrlsOrder = Order; -export type OrderBy = Partial>; - export interface ShortUrlsListParams { page?: string; itemsPerPage?: number; - orderBy?: OrderBy; + orderBy?: ShortUrlsOrder; } const initialState: ShortUrlsListParams = { page: '1', - orderBy: { dateCreated: 'DESC' }, }; export default buildReducer({ diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index bd7c7daf..2394ddd5 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -22,7 +22,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: // Components bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar'); bottle.decorator('ShortUrlsList', connect( - [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ], + [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList', 'settings' ], [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ], )); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 10090847..11131f97 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -5,7 +5,7 @@ import { OptionalString } from '../../../src/utils/utils'; import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; import { ShortUrl } from '../../../src/short-urls/data'; import { Visit } from '../../../src/visits/types'; -import { OrderDir } from '../../../src/utils/helpers/ordering'; +import { ShortUrlsOrder } from '../../../src/short-urls/reducers/shortUrlsListParams'; describe('ShlinkApiClient', () => { const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; @@ -33,9 +33,9 @@ describe('ShlinkApiClient', () => { }); it.each([ - [{ visits: 'DESC' as OrderDir }, 'visits-DESC' ], - [{ longUrl: 'ASC' as OrderDir }, 'longUrl-ASC' ], - [{ longUrl: undefined as OrderDir }, undefined ], + [ { field: 'visits', dir: 'DESC' } as ShortUrlsOrder, 'visits-DESC' ], + [ { field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, 'longUrl-ASC' ], + [ { field: 'longUrl', dir: undefined } as ShortUrlsOrder, undefined ], ])('parses orderBy in params', async (orderBy, expectedOrderBy) => { const axiosSpy = createAxiosMock({ data: expectedList, diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 70c5d4b7..272e8905 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -1,10 +1,12 @@ import reducer, { SET_SETTINGS, + DEFAULT_SHORT_URLS_ORDERING, toggleRealTimeUpdates, setRealTimeUpdatesInterval, setShortUrlCreationSettings, setUiSettings, setVisitsSettings, + setTagsSettings, } from '../../../src/settings/reducers/settings'; describe('settingsReducer', () => { @@ -12,7 +14,8 @@ describe('settingsReducer', () => { const shortUrlCreation = { validateUrls: false }; const ui = { theme: 'light' }; const visits = { defaultInterval: 'last30Days' }; - const settings = { realTimeUpdates, shortUrlCreation, ui, visits }; + const shortUrlList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING }; + const settings = { realTimeUpdates, shortUrlCreation, ui, visits, shortUrlList }; describe('reducer', () => { it('returns realTimeUpdates when action is SET_SETTINGS', () => { @@ -59,4 +62,12 @@ describe('settingsReducer', () => { expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180Days' } }); }); }); + + describe('setTagsSettings', () => { + it('creates action to set tags settings', () => { + const result = setTagsSettings({ defaultMode: 'list' }); + + expect(result).toEqual({ type: SET_SETTINGS, tags: { defaultMode: 'list' } }); + }); + }); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index ebb09941..71a1dd5f 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -8,10 +8,11 @@ import { ShortUrl } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import SortingDropdown from '../../src/utils/SortingDropdown'; -import { OrderableFields, OrderBy } from '../../src/short-urls/reducers/shortUrlsListParams'; +import { OrderableFields, ShortUrlsOrder } from '../../src/short-urls/reducers/shortUrlsListParams'; import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; +import { Settings } from '../../src/settings/reducers/settings'; describe('', () => { let wrapper: ShallowWrapper; @@ -32,7 +33,7 @@ describe('', () => { }, }); const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, SearchBar); - const createWrapper = (orderBy: OrderBy = {}) => shallow( + const createWrapper = (orderBy: ShortUrlsOrder = {}) => shallow( ({ mercureInfo: { loading: true } })} listShortUrls={listShortUrlsMock} @@ -43,6 +44,7 @@ describe('', () => { shortUrlsList={shortUrlsList} history={Mock.of({ push })} selectedServer={Mock.of({ id: '1' })} + settings={Mock.all()} />, ).dive(); // Dive is needed as this component is wrapped in a HOC @@ -91,20 +93,16 @@ describe('', () => { it('handles order through table', () => { const orderByColumn: (field: OrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); - orderByColumn('visits')(); - orderByColumn('title')(); - orderByColumn('shortCode')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); - expect(listShortUrlsMock).toHaveBeenCalledTimes(3); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ - orderBy: { visits: 'ASC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ - orderBy: { title: 'ASC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ - orderBy: { shortCode: 'ASC' }, - })); + orderByColumn('visits')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); + + orderByColumn('title')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'title', dir: 'ASC' }); + + orderByColumn('shortCode')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); }); it('handles order through dropdown', () => { @@ -118,21 +116,12 @@ describe('', () => { wrapper.find(SortingDropdown).simulate('change', undefined, undefined); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); - - expect(listShortUrlsMock).toHaveBeenCalledTimes(3); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ - orderBy: { visits: 'ASC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ - orderBy: { shortCode: 'DESC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ orderBy: undefined })); }); it.each([ - [ Mock.of({ visits: 'ASC' }), 'visits', 'ASC' ], - [ Mock.of({ title: 'DESC' }), 'title', 'DESC' ], - [ Mock.of(), undefined, undefined ], + [ Mock.of({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC' ], + [ Mock.of({ field: 'title', dir: 'DESC' }), 'title', 'DESC' ], + [ Mock.of(), undefined, undefined ], ])('has expected initial ordering', (initialOrderBy, field, dir) => { const wrapper = createWrapper(initialOrderBy); diff --git a/test/short-urls/reducers/shortUrlsListParams.test.ts b/test/short-urls/reducers/shortUrlsListParams.test.ts index 871ac7ff..6acace25 100644 --- a/test/short-urls/reducers/shortUrlsListParams.test.ts +++ b/test/short-urls/reducers/shortUrlsListParams.test.ts @@ -10,14 +10,10 @@ describe('shortUrlsListParamsReducer', () => { expect(reducer(undefined, { type: LIST_SHORT_URLS, params: { searchTerm: 'foo', page: '2' } } as any)).toEqual({ page: '2', searchTerm: 'foo', - orderBy: { dateCreated: 'DESC' }, })); it('returns default value when action is RESET_SHORT_URL_PARAMS', () => - expect(reducer(undefined, { type: RESET_SHORT_URL_PARAMS } as any)).toEqual({ - page: '1', - orderBy: { dateCreated: 'DESC' }, - })); + expect(reducer(undefined, { type: RESET_SHORT_URL_PARAMS } as any)).toEqual({ page: '1' })); }); describe('resetShortUrlParams', () => { From 275aee4de26f3a84a0e57280b5fb911398ba67ca Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 13:39:51 +0100 Subject: [PATCH 21/49] Removed shortUrlsListParams reducer, as the state is now handled internally in the component --- src/container/types.ts | 2 -- src/reducers/index.ts | 2 -- src/servers/reducers/selectedServer.ts | 2 -- src/short-urls/ShortUrlsList.tsx | 10 ++------ .../reducers/shortUrlsListParams.ts | 21 ----------------- src/short-urls/services/provideServices.ts | 6 ++--- test/servers/reducers/selectedServer.test.ts | 10 ++++---- test/short-urls/ShortUrlsList.test.tsx | 6 ++--- .../reducers/shortUrlsListParams.test.ts | 23 ------------------- 9 files changed, 10 insertions(+), 72 deletions(-) delete mode 100644 test/short-urls/reducers/shortUrlsListParams.test.ts diff --git a/src/container/types.ts b/src/container/types.ts index f4d20282..e3289da8 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings'; import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; -import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams'; import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import { TagDeletion } from '../tags/reducers/tagDelete'; import { TagEdition } from '../tags/reducers/tagEdit'; @@ -20,7 +19,6 @@ export interface ShlinkState { servers: ServersMap; selectedServer: SelectedServer; shortUrlsList: ShortUrlsList; - shortUrlsListParams: ShortUrlsListParams; shortUrlCreationResult: ShortUrlCreation; shortUrlDeletion: ShortUrlDeletion; shortUrlEdition: ShortUrlEdition; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 2257cdda..3c85f168 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,7 +2,6 @@ import { combineReducers } from 'redux'; import serversReducer from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; -import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; @@ -24,7 +23,6 @@ export default combineReducers({ servers: serversReducer, selectedServer: selectedServerReducer, shortUrlsList: shortUrlsListReducer, - shortUrlsListParams: shortUrlsListParamsReducer, shortUrlCreationResult: shortUrlCreationReducer, shortUrlDeletion: shortUrlDeletionReducer, shortUrlEdition: shortUrlEditionReducer, diff --git a/src/servers/reducers/selectedServer.ts b/src/servers/reducers/selectedServer.ts index 38c7c133..b531547c 100644 --- a/src/servers/reducers/selectedServer.ts +++ b/src/servers/reducers/selectedServer.ts @@ -1,6 +1,5 @@ import { identity, memoizeWith, pipe } from 'ramda'; import { Action, Dispatch } from 'redux'; -import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; import { SelectedServer } from '../data'; import { GetState } from '../../container/types'; @@ -53,7 +52,6 @@ export const selectServer = ( getState: GetState, ) => { dispatch(resetSelectedServer()); - dispatch(resetShortUrlParams()); const { servers } = getState(); const selectedServer = servers[serverId]; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index fe0623e7..dc81d6d4 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -11,7 +11,7 @@ import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import { OrderableFields, ShortUrlsListParams, ShortUrlsOrder, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; +import { OrderableFields, ShortUrlsOrder, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; @@ -20,15 +20,11 @@ interface ShortUrlsListProps extends RouteComponentProps void; - shortUrlsListParams: ShortUrlsListParams; - resetShortUrlParams: () => void; settings: Settings; } const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) => boundToMercureHub(({ listShortUrls, - resetShortUrlParams, - shortUrlsListParams, match, location, history, @@ -37,8 +33,7 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = settings, }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); - const { orderBy } = shortUrlsListParams; - const initialOrderBy = orderBy ?? settings.shortUrlList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; + const initialOrderBy = settings.shortUrlList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; const [ order, setOrder ] = useState(initialOrderBy); const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); @@ -53,7 +48,6 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = (tags) => toFirstPage({ tags }), ); - useEffect(() => resetShortUrlParams, []); useEffect(() => { listShortUrls({ page: match.params.page, diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts index f2c2b0bc..299f9d1a 100644 --- a/src/short-urls/reducers/shortUrlsListParams.ts +++ b/src/short-urls/reducers/shortUrlsListParams.ts @@ -1,8 +1,4 @@ -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { Order } from '../../utils/helpers/ordering'; -import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList'; - -export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; export const SORTABLE_FIELDS = { dateCreated: 'Created at', @@ -15,20 +11,3 @@ export const SORTABLE_FIELDS = { export type OrderableFields = keyof typeof SORTABLE_FIELDS; export type ShortUrlsOrder = Order; - -export interface ShortUrlsListParams { - page?: string; - itemsPerPage?: number; - orderBy?: ShortUrlsOrder; -} - -const initialState: ShortUrlsListParams = { - page: '1', -}; - -export default buildReducer({ - [LIST_SHORT_URLS]: (state, { params }) => ({ ...state, ...params }), - [RESET_SHORT_URL_PARAMS]: () => initialState, -}, initialState); - -export const resetShortUrlParams = buildActionCreator(RESET_SHORT_URL_PARAMS); diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 2394ddd5..45b745f2 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -9,7 +9,6 @@ import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion'; -import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { editShortUrl } from '../reducers/shortUrlEdition'; import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; @@ -22,8 +21,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: // Components bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar'); bottle.decorator('ShortUrlsList', connect( - [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList', 'settings' ], - [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ], + [ 'selectedServer', 'mercureInfo', 'shortUrlsList', 'settings' ], + [ 'listShortUrls', 'createNewVisits', 'loadMercureInfo' ], )); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); @@ -56,7 +55,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); - bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams); bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl); diff --git a/test/servers/reducers/selectedServer.test.ts b/test/servers/reducers/selectedServer.test.ts index 030b17f4..acd896ef 100644 --- a/test/servers/reducers/selectedServer.test.ts +++ b/test/servers/reducers/selectedServer.test.ts @@ -8,7 +8,6 @@ import reducer, { MAX_FALLBACK_VERSION, MIN_FALLBACK_VERSION, } from '../../../src/servers/reducers/selectedServer'; -import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams'; import { ShlinkState } from '../../../src/container/types'; import { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data'; @@ -62,10 +61,9 @@ describe('selectedServerReducer', () => { await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState); - expect(dispatch).toHaveBeenCalledTimes(4); + expect(dispatch).toHaveBeenCalledTimes(3); expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: RESET_SHORT_URL_PARAMS }); - expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); expect(loadMercureInfo).toHaveBeenCalledTimes(1); }); @@ -89,7 +87,7 @@ describe('selectedServerReducer', () => { await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState); expect(apiClientMock.health).toHaveBeenCalled(); - expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); expect(loadMercureInfo).not.toHaveBeenCalled(); }); @@ -102,7 +100,7 @@ describe('selectedServerReducer', () => { expect(getState).toHaveBeenCalled(); expect(apiClientMock.health).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer }); expect(loadMercureInfo).not.toHaveBeenCalled(); }); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 71a1dd5f..8ceadcbc 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -33,18 +33,16 @@ describe('', () => { }, }); const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, SearchBar); - const createWrapper = (orderBy: ShortUrlsOrder = {}) => shallow( + const createWrapper = (defaultOrdering: ShortUrlsOrder = {}) => shallow( ({ mercureInfo: { loading: true } })} listShortUrls={listShortUrlsMock} - resetShortUrlParams={jest.fn()} - shortUrlsListParams={{ page: '1', orderBy }} match={Mock.of>({ params: {} })} location={Mock.of({ search: '?tags=test%20tag&search=example.com' })} shortUrlsList={shortUrlsList} history={Mock.of({ push })} selectedServer={Mock.of({ id: '1' })} - settings={Mock.all()} + settings={Mock.of({ shortUrlList: { defaultOrdering } })} />, ).dive(); // Dive is needed as this component is wrapped in a HOC diff --git a/test/short-urls/reducers/shortUrlsListParams.test.ts b/test/short-urls/reducers/shortUrlsListParams.test.ts deleted file mode 100644 index 6acace25..00000000 --- a/test/short-urls/reducers/shortUrlsListParams.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import reducer, { - RESET_SHORT_URL_PARAMS, - resetShortUrlParams, -} from '../../../src/short-urls/reducers/shortUrlsListParams'; -import { LIST_SHORT_URLS } from '../../../src/short-urls/reducers/shortUrlsList'; - -describe('shortUrlsListParamsReducer', () => { - describe('reducer', () => { - it('returns params when action is LIST_SHORT_URLS', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS, params: { searchTerm: 'foo', page: '2' } } as any)).toEqual({ - page: '2', - searchTerm: 'foo', - })); - - it('returns default value when action is RESET_SHORT_URL_PARAMS', () => - expect(reducer(undefined, { type: RESET_SHORT_URL_PARAMS } as any)).toEqual({ page: '1' })); - }); - - describe('resetShortUrlParams', () => { - it('returns proper action', () => - expect(resetShortUrlParams()).toEqual({ type: RESET_SHORT_URL_PARAMS })); - }); -}); From d4356ba6e64725b34813facc61d68877f13833b5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 13:47:27 +0100 Subject: [PATCH 22/49] Moved types from old shortUrlsListParams reducer, to the data index file --- src/api/types/index.ts | 3 +-- src/settings/reducers/settings.ts | 2 +- src/short-urls/ShortUrlsList.tsx | 2 +- src/short-urls/ShortUrlsTable.tsx | 2 +- src/short-urls/data/index.ts | 13 +++++++++++++ src/short-urls/reducers/shortUrlsListParams.ts | 13 ------------- test/api/services/ShlinkApiClient.test.ts | 3 +-- test/short-urls/ShortUrlsList.test.tsx | 3 +-- test/short-urls/ShortUrlsTable.test.tsx | 2 +- 9 files changed, 20 insertions(+), 23 deletions(-) delete mode 100644 src/short-urls/reducers/shortUrlsListParams.ts diff --git a/src/api/types/index.ts b/src/api/types/index.ts index af833bb2..4c656819 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -1,7 +1,6 @@ import { Visit } from '../../visits/types'; import { OptionalString } from '../../utils/utils'; -import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; -import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; +import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data'; export interface ShlinkShortUrlsResponse { data: ShortUrl[]; diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index fb2932d6..c2f0a8da 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -5,7 +5,7 @@ import { RecursivePartial } from '../../utils/utils'; import { Theme } from '../../utils/theme'; import { DateInterval } from '../../utils/dates/types'; import { TagsOrder } from '../../tags/data/TagsListChildrenProps'; -import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; +import { ShortUrlsOrder } from '../../short-urls/data'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index dc81d6d4..b3e63943 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -11,10 +11,10 @@ import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; -import { OrderableFields, ShortUrlsOrder, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; +import { OrderableFields, ShortUrlsOrder, SORTABLE_FIELDS } from './data'; interface ShortUrlsListProps extends RouteComponentProps { selectedServer: SelectedServer; diff --git a/src/short-urls/ShortUrlsTable.tsx b/src/short-urls/ShortUrlsTable.tsx index ae73ad9e..df386f88 100644 --- a/src/short-urls/ShortUrlsTable.tsx +++ b/src/short-urls/ShortUrlsTable.tsx @@ -5,7 +5,7 @@ import { SelectedServer } from '../servers/data'; import { supportsShortUrlTitle } from '../utils/helpers/features'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsRowProps } from './helpers/ShortUrlsRow'; -import { OrderableFields } from './reducers/shortUrlsListParams'; +import { OrderableFields } from './data'; import './ShortUrlsTable.scss'; export interface ShortUrlsTableProps { diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 5b436bdf..f5a001cd 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -1,4 +1,5 @@ import { Nullable, OptionalString } from '../../utils/utils'; +import { Order } from '../../utils/helpers/ordering'; export interface EditShortUrlData { longUrl?: string; @@ -50,3 +51,15 @@ export interface ShortUrlIdentifier { shortCode: string; domain: OptionalString; } + +export const SORTABLE_FIELDS = { + dateCreated: 'Created at', + shortCode: 'Short URL', + longUrl: 'Long URL', + title: 'Title', + visits: 'Visits', +}; + +export type OrderableFields = keyof typeof SORTABLE_FIELDS; + +export type ShortUrlsOrder = Order; diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts deleted file mode 100644 index 299f9d1a..00000000 --- a/src/short-urls/reducers/shortUrlsListParams.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Order } from '../../utils/helpers/ordering'; - -export const SORTABLE_FIELDS = { - dateCreated: 'Created at', - shortCode: 'Short URL', - longUrl: 'Long URL', - title: 'Title', - visits: 'Visits', -}; - -export type OrderableFields = keyof typeof SORTABLE_FIELDS; - -export type ShortUrlsOrder = Order; diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 11131f97..9ce5af41 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -3,9 +3,8 @@ import { Mock } from 'ts-mockery'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; import { OptionalString } from '../../../src/utils/utils'; import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; -import { ShortUrl } from '../../../src/short-urls/data'; +import { ShortUrl, ShortUrlsOrder } from '../../../src/short-urls/data'; import { Visit } from '../../../src/visits/types'; -import { ShortUrlsOrder } from '../../../src/short-urls/reducers/shortUrlsListParams'; describe('ShlinkApiClient', () => { const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 8ceadcbc..e6379e14 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -4,11 +4,10 @@ import { Mock } from 'ts-mockery'; import { History, Location } from 'history'; import { match } from 'react-router'; import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList'; -import { ShortUrl } from '../../src/short-urls/data'; +import { OrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import SortingDropdown from '../../src/utils/SortingDropdown'; -import { OrderableFields, ShortUrlsOrder } from '../../src/short-urls/reducers/shortUrlsListParams'; import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; diff --git a/test/short-urls/ShortUrlsTable.test.tsx b/test/short-urls/ShortUrlsTable.test.tsx index 7164e40e..bcbccd23 100644 --- a/test/short-urls/ShortUrlsTable.test.tsx +++ b/test/short-urls/ShortUrlsTable.test.tsx @@ -2,10 +2,10 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable'; -import { OrderableFields, SORTABLE_FIELDS } from '../../src/short-urls/reducers/shortUrlsListParams'; import { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList'; import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { SemVer } from '../../src/utils/helpers/version'; +import { OrderableFields, SORTABLE_FIELDS } from '../../src/short-urls/data'; describe('', () => { let wrapper: ShallowWrapper; From de32d899bcc21d88698714592d1dcd0eeccd72c0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 14:15:28 +0100 Subject: [PATCH 23/49] Added new settings card to customize short URLs lists --- src/settings/Settings.tsx | 15 ++++++-- src/settings/ShortUrlsList.tsx | 24 ++++++++++++ src/settings/Tags.tsx | 2 +- src/settings/reducers/settings.ts | 11 ++++-- src/settings/services/provideServices.ts | 17 ++++++++- src/short-urls/ShortUrlsList.tsx | 2 +- test/settings/Settings.test.tsx | 4 +- test/settings/ShortUrlsList.test.tsx | 48 ++++++++++++++++++++++++ test/settings/reducers/settings.test.ts | 13 ++++++- test/short-urls/ShortUrlsList.test.tsx | 2 +- 10 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 src/settings/ShortUrlsList.tsx create mode 100644 test/settings/ShortUrlsList.test.tsx diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index e9d3369f..81d047ed 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -16,13 +16,20 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => ( ); -const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC, Tags: FC) => () => ( +const Settings = ( + RealTimeUpdates: FC, + ShortUrlCreation: FC, + ShortUrlsList: FC, + UserInterface: FC, + Visits: FC, + Tags: 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 + [ , ], // eslint-disable-line react/jsx-key + [ , ], // eslint-disable-line react/jsx-key ]} /> diff --git a/src/settings/ShortUrlsList.tsx b/src/settings/ShortUrlsList.tsx new file mode 100644 index 00000000..aadbb502 --- /dev/null +++ b/src/settings/ShortUrlsList.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react'; +import { FormGroup } from 'reactstrap'; +import SortingDropdown from '../utils/SortingDropdown'; +import { SORTABLE_FIELDS } from '../short-urls/data'; +import { SimpleCard } from '../utils/SimpleCard'; +import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from './reducers/settings'; + +interface ShortUrlsListProps { + settings: Settings; + setShortUrlsListSettings: (settings: ShortUrlsListSettings) => void; +} + +export const ShortUrlsList: FC = ({ settings: { shortUrlsList }, setShortUrlsListSettings }) => ( + + + + setShortUrlsListSettings({ defaultOrdering: { field, dir } })} + /> + + +); diff --git a/src/settings/Tags.tsx b/src/settings/Tags.tsx index 63bc4aab..cbbd2af8 100644 --- a/src/settings/Tags.tsx +++ b/src/settings/Tags.tsx @@ -13,7 +13,7 @@ interface TagsProps { } export const Tags: FC = ({ settings: { tags }, setTagsSettings }) => ( - + ({ + type: SET_SETTINGS, + shortUrlsList: settings, +}); + export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({ type: SET_SETTINGS, ui: settings, diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts index 93d82584..c54d37aa 100644 --- a/src/settings/services/provideServices.ts +++ b/src/settings/services/provideServices.ts @@ -4,6 +4,7 @@ import Settings from '../Settings'; import { setRealTimeUpdatesInterval, setShortUrlCreationSettings, + setShortUrlsListSettings, setTagsSettings, setUiSettings, setVisitsSettings, @@ -15,10 +16,20 @@ import { ShortUrlCreation } from '../ShortUrlCreation'; import { UserInterface } from '../UserInterface'; import { Visits } from '../Visits'; import { Tags } from '../Tags'; +import { ShortUrlsList } from '../ShortUrlsList'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components - bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits', 'Tags'); + bottle.serviceFactory( + 'Settings', + Settings, + 'RealTimeUpdates', + 'ShortUrlCreation', + 'ShortUrlsListSettings', + 'UserInterface', + 'Visits', + 'Tags', + ); bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); @@ -40,10 +51,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('Tags', () => Tags); bottle.decorator('Tags', connect([ 'settings' ], [ 'setTagsSettings' ])); + bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsList); + bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ])); + // Actions bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings); + bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings); bottle.serviceFactory('setUiSettings', () => setUiSettings); bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings); bottle.serviceFactory('setTagsSettings', () => setTagsSettings); diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index b3e63943..09227fff 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -33,7 +33,7 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = settings, }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); - const initialOrderBy = settings.shortUrlList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; + const initialOrderBy = settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; const [ order, setOrder ] = useState(initialOrderBy); const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); diff --git a/test/settings/Settings.test.tsx b/test/settings/Settings.test.tsx index e8f3560c..0810cf8f 100644 --- a/test/settings/Settings.test.tsx +++ b/test/settings/Settings.test.tsx @@ -4,7 +4,7 @@ import NoMenuLayout from '../../src/common/NoMenuLayout'; describe('', () => { const Component = () => null; - const Settings = createSettings(Component, Component, Component, Component, Component); + const Settings = createSettings(Component, Component, Component, Component, Component, Component); it('renders a no-menu layout with the expected settings sections', () => { const wrapper = shallow(); @@ -13,6 +13,6 @@ describe('', () => { expect(layout).toHaveLength(1); expect(sections).toHaveLength(1); - expect((sections.prop('items') as any[]).flat()).toHaveLength(5); + expect((sections.prop('items') as any[]).flat()).toHaveLength(6); }); }); diff --git a/test/settings/ShortUrlsList.test.tsx b/test/settings/ShortUrlsList.test.tsx new file mode 100644 index 00000000..3c1136de --- /dev/null +++ b/test/settings/ShortUrlsList.test.tsx @@ -0,0 +1,48 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from '../../src/settings/reducers/settings'; +import { ShortUrlsList } from '../../src/settings/ShortUrlsList'; +import SortingDropdown from '../../src/utils/SortingDropdown'; +import { ShortUrlsOrder } from '../../src/short-urls/data'; + +describe('', () => { + let wrapper: ShallowWrapper; + const setSettings = jest.fn(); + const createWrapper = (shortUrlsList?: ShortUrlsListSettings) => { + wrapper = shallow( + ({ shortUrlsList })} setShortUrlsListSettings={setSettings} />, + ); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + afterEach(jest.clearAllMocks); + + it.each([ + [ undefined, DEFAULT_SHORT_URLS_ORDERING ], + [{}, DEFAULT_SHORT_URLS_ORDERING ], + [{ defaultOrdering: {} }, {}], + [{ defaultOrdering: { field: 'longUrl', dir: 'DESC' } as ShortUrlsOrder }, { field: 'longUrl', dir: 'DESC' }], + [{ defaultOrdering: { field: 'visits', dir: 'ASC' } as ShortUrlsOrder }, { field: 'visits', dir: 'ASC' }], + ])('shows expected ordering', (shortUrlsList, expectedOrder) => { + const wrapper = createWrapper(shortUrlsList); + const dropdown = wrapper.find(SortingDropdown); + + expect(dropdown.prop('order')).toEqual(expectedOrder); + }); + + it.each([ + [ undefined, undefined ], + [ 'longUrl', 'ASC' ], + [ 'visits', undefined ], + [ 'title', 'DESC' ], + ])('invokes setSettings when ordering changes', (field, dir) => { + const wrapper = createWrapper(); + const dropdown = wrapper.find(SortingDropdown); + + expect(setSettings).not.toHaveBeenCalled(); + dropdown.simulate('change', field, dir); + expect(setSettings).toHaveBeenCalledWith({ defaultOrdering: { field, dir } }); + }); +}); diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 272e8905..09a7b14c 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -7,6 +7,7 @@ import reducer, { setUiSettings, setVisitsSettings, setTagsSettings, + setShortUrlsListSettings, } from '../../../src/settings/reducers/settings'; describe('settingsReducer', () => { @@ -14,8 +15,8 @@ describe('settingsReducer', () => { const shortUrlCreation = { validateUrls: false }; const ui = { theme: 'light' }; const visits = { defaultInterval: 'last30Days' }; - const shortUrlList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING }; - const settings = { realTimeUpdates, shortUrlCreation, ui, visits, shortUrlList }; + const shortUrlsList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING }; + const settings = { realTimeUpdates, shortUrlCreation, ui, visits, shortUrlsList }; describe('reducer', () => { it('returns realTimeUpdates when action is SET_SETTINGS', () => { @@ -70,4 +71,12 @@ describe('settingsReducer', () => { expect(result).toEqual({ type: SET_SETTINGS, tags: { defaultMode: 'list' } }); }); }); + + describe('setShortUrlsListSettings', () => { + it('creates action to set short URLs list settings', () => { + const result = setShortUrlsListSettings({ defaultOrdering: DEFAULT_SHORT_URLS_ORDERING }); + + expect(result).toEqual({ type: SET_SETTINGS, shortUrlsList: { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING } }); + }); + }); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index e6379e14..3602667e 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -41,7 +41,7 @@ describe('', () => { shortUrlsList={shortUrlsList} history={Mock.of({ push })} selectedServer={Mock.of({ id: '1' })} - settings={Mock.of({ shortUrlList: { defaultOrdering } })} + settings={Mock.of({ shortUrlsList: { defaultOrdering } })} />, ).dive(); // Dive is needed as this component is wrapped in a HOC From 86c6acb7b8f29b77a2399948fabf1f51ef588d47 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 14:16:42 +0100 Subject: [PATCH 24/49] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce34677b..18fbd3ab 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/), * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. +* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs. ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. From eaadd6f7af0e4fdce9e59739e45b115ffade911d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 24 Dec 2021 15:05:15 +0100 Subject: [PATCH 25/49] Removed params param when dispatching list short RULs action, as it was used by a reducer that has been deleted --- src/short-urls/reducers/shortUrlsList.ts | 5 ++--- test/short-urls/reducers/shortUrlsList.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index e50d931c..894975ac 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -24,7 +24,6 @@ export interface ShortUrlsList { export interface ListShortUrlsAction extends Action { shortUrls: ShlinkShortUrlsResponse; - params: ShlinkShortUrlsListParams; } export type ListShortUrlsCombinedAction = ( @@ -108,8 +107,8 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( try { const shortUrls = await listShortUrls(params); - dispatch({ type: LIST_SHORT_URLS, shortUrls, params }); + dispatch({ type: LIST_SHORT_URLS, shortUrls }); } catch (e) { - dispatch({ type: LIST_SHORT_URLS_ERROR, params }); + dispatch({ type: LIST_SHORT_URLS_ERROR }); } }; diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index e4ae692d..031f6a23 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -175,7 +175,7 @@ describe('shortUrlsListReducer', () => { expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS, shortUrls: [], params: {} }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS, shortUrls: [] }); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); @@ -188,7 +188,7 @@ describe('shortUrlsListReducer', () => { expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS_ERROR, params: {} }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS_ERROR }); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); From 76fb45c97e9c62723b9417d2ac902fa912bc442e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Dec 2021 10:24:37 +0100 Subject: [PATCH 26/49] Renamed constants holding orderable fields for short URLs and tags --- src/settings/ShortUrlsList.tsx | 4 ++-- src/settings/Tags.tsx | 4 ++-- src/short-urls/ShortUrlsList.tsx | 10 +++++----- src/short-urls/ShortUrlsTable.tsx | 6 +++--- src/short-urls/data/index.ts | 6 +++--- src/tags/TagsList.tsx | 15 ++++++++++++--- src/tags/TagsTable.tsx | 4 ++-- src/tags/data/TagsListChildrenProps.ts | 6 +++--- test/short-urls/ShortUrlsList.test.tsx | 8 ++++---- test/short-urls/ShortUrlsTable.test.tsx | 6 +++--- test/tags/TagsList.test.tsx | 4 ++-- 11 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/settings/ShortUrlsList.tsx b/src/settings/ShortUrlsList.tsx index aadbb502..57407afc 100644 --- a/src/settings/ShortUrlsList.tsx +++ b/src/settings/ShortUrlsList.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { FormGroup } from 'reactstrap'; import SortingDropdown from '../utils/SortingDropdown'; -import { SORTABLE_FIELDS } from '../short-urls/data'; +import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data'; import { SimpleCard } from '../utils/SimpleCard'; import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from './reducers/settings'; @@ -15,7 +15,7 @@ export const ShortUrlsList: FC = ({ settings: { shortUrlsLis setShortUrlsListSettings({ defaultOrdering: { field, dir } })} /> diff --git a/src/settings/Tags.tsx b/src/settings/Tags.tsx index cbbd2af8..6fbc2194 100644 --- a/src/settings/Tags.tsx +++ b/src/settings/Tags.tsx @@ -4,7 +4,7 @@ import { SimpleCard } from '../utils/SimpleCard'; import { TagsModeDropdown } from '../tags/TagsModeDropdown'; import { capitalize } from '../utils/utils'; import SortingDropdown from '../utils/SortingDropdown'; -import { SORTABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; +import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; import { Settings, TagsSettings } from './reducers/settings'; interface TagsProps { @@ -26,7 +26,7 @@ export const Tags: FC = ({ settings: { tags }, setTagsSettings }) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })} /> diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 09227fff..a3722d38 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -14,7 +14,7 @@ import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; -import { OrderableFields, ShortUrlsOrder, SORTABLE_FIELDS } from './data'; +import { ShortUrlsOrderableFields, ShortUrlsOrder, SHORT_URLS_ORDERABLE_FIELDS } from './data'; interface ShortUrlsListProps extends RouteComponentProps { selectedServer: SelectedServer; @@ -39,10 +39,10 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); const { pagination } = shortUrlsList?.shortUrls ?? {}; - const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => setOrder({ field, dir }); - const orderByColumn = (field: OrderableFields) => () => + const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => setOrder({ field, dir }); + const orderByColumn = (field: ShortUrlsOrderableFields) => () => handleOrderBy(field, determineOrderDir(field, order.field, order.dir)); - const renderOrderIcon = (field: OrderableFields) => ; + const renderOrderIcon = (field: ShortUrlsOrderableFields) => ; const addTag = pipe( (newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','), (tags) => toFirstPage({ tags }), @@ -64,7 +64,7 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = <>
- +
() => void; - renderOrderIcon?: (column: OrderableFields) => ReactNode; + orderByColumn?: (column: ShortUrlsOrderableFields) => () => void; + renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode; shortUrlsList: ShortUrlsListState; selectedServer: SelectedServer; onTagClick?: (tag: string) => void; diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index f5a001cd..8199497d 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -52,7 +52,7 @@ export interface ShortUrlIdentifier { domain: OptionalString; } -export const SORTABLE_FIELDS = { +export const SHORT_URLS_ORDERABLE_FIELDS = { dateCreated: 'Created at', shortCode: 'Short URL', longUrl: 'Long URL', @@ -60,6 +60,6 @@ export const SORTABLE_FIELDS = { visits: 'Visits', }; -export type OrderableFields = keyof typeof SORTABLE_FIELDS; +export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS; -export type ShortUrlsOrder = Order; +export type ShortUrlsOrder = Order; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index 63832b53..bcc3934f 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -12,7 +12,12 @@ import { Settings, TagsMode } from '../settings/reducers/settings'; import { determineOrderDir, sortList } from '../utils/helpers/ordering'; import SortingDropdown from '../utils/SortingDropdown'; import { TagsList as TagsListState } from './reducers/tagsList'; -import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps'; +import { + TagsOrderableFields, + TAGS_ORDERABLE_FIELDS, + TagsListChildrenProps, + TagsOrder, +} from './data/TagsListChildrenProps'; import { TagsModeDropdown } from './TagsModeDropdown'; import { NormalizedTag } from './data'; import { TagsTableProps } from './TagsTable'; @@ -55,7 +60,7 @@ const TagsList = (TagsCards: FC, TagsTable: FC () => { + const orderByColumn = (field: TagsOrderableFields) => () => { const dir = determineOrderDir(field, order.field, order.dir); setOrder({ field: dir ? field : undefined, dir }); @@ -88,7 +93,11 @@ const TagsList = (TagsCards: FC, TagsTable: FC
- setOrder({ field, dir })} /> + setOrder({ field, dir })} + />
{renderContent()} diff --git a/src/tags/TagsTable.tsx b/src/tags/TagsTable.tsx index cf24542c..94876b65 100644 --- a/src/tags/TagsTable.tsx +++ b/src/tags/TagsTable.tsx @@ -6,12 +6,12 @@ import SimplePaginator from '../common/SimplePaginator'; import { useQueryState } from '../utils/helpers/hooks'; import { parseQuery } from '../utils/helpers/query'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; -import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps'; +import { TagsOrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps'; import { TagsTableRowProps } from './TagsTableRow'; import './TagsTable.scss'; export interface TagsTableProps extends TagsListChildrenProps { - orderByColumn: (field: OrderableFields) => () => void; + orderByColumn: (field: TagsOrderableFields) => () => void; currentOrder: TagsOrder; } diff --git a/src/tags/data/TagsListChildrenProps.ts b/src/tags/data/TagsListChildrenProps.ts index e8e476a8..54837ac7 100644 --- a/src/tags/data/TagsListChildrenProps.ts +++ b/src/tags/data/TagsListChildrenProps.ts @@ -2,15 +2,15 @@ import { SelectedServer } from '../../servers/data'; import { Order } from '../../utils/helpers/ordering'; import { NormalizedTag } from './index'; -export const SORTABLE_FIELDS = { +export const TAGS_ORDERABLE_FIELDS = { tag: 'Tag', shortUrls: 'Short URLs', visits: 'Visits', }; -export type OrderableFields = keyof typeof SORTABLE_FIELDS; +export type TagsOrderableFields = keyof typeof TAGS_ORDERABLE_FIELDS; -export type TagsOrder = Order; +export type TagsOrder = Order; export interface TagsListChildrenProps { sortedTags: NormalizedTag[]; diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 3602667e..81871a48 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -4,7 +4,7 @@ import { Mock } from 'ts-mockery'; import { History, Location } from 'history'; import { match } from 'react-router'; import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList'; -import { OrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data'; +import { ShortUrlsOrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import SortingDropdown from '../../src/utils/SortingDropdown'; @@ -75,8 +75,8 @@ describe('', () => { }); it('invokes order icon rendering', () => { - const renderIcon = (field: OrderableFields) => - (wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: OrderableFields) => ReactElement)(field); + const renderIcon = (field: ShortUrlsOrderableFields) => + (wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: ShortUrlsOrderableFields) => ReactElement)(field); expect(renderIcon('visits').props.currentOrder).toEqual({}); @@ -88,7 +88,7 @@ describe('', () => { }); it('handles order through table', () => { - const orderByColumn: (field: OrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); + const orderByColumn: (field: ShortUrlsOrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); diff --git a/test/short-urls/ShortUrlsTable.test.tsx b/test/short-urls/ShortUrlsTable.test.tsx index bcbccd23..a0be282e 100644 --- a/test/short-urls/ShortUrlsTable.test.tsx +++ b/test/short-urls/ShortUrlsTable.test.tsx @@ -5,7 +5,7 @@ import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/Sh import { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList'; import { ReachableServer, SelectedServer } from '../../src/servers/data'; import { SemVer } from '../../src/utils/helpers/version'; -import { OrderableFields, SORTABLE_FIELDS } from '../../src/short-urls/data'; +import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from '../../src/short-urls/data'; describe('', () => { let wrapper: ShallowWrapper; @@ -51,8 +51,8 @@ describe('', () => { .find('thead') .find('tr') .find('th') - .filterWhere((e) => e.text().includes(SORTABLE_FIELDS[orderableField as OrderableFields])); - const sortableFields = Object.keys(SORTABLE_FIELDS).filter((sortableField) => sortableField !== 'title'); + .filterWhere((e) => e.text().includes(SHORT_URLS_ORDERABLE_FIELDS[orderableField as ShortUrlsOrderableFields])); + const sortableFields = Object.keys(SHORT_URLS_ORDERABLE_FIELDS).filter((sortableField) => sortableField !== 'title'); expect.assertions(sortableFields.length); sortableFields.forEach((sortableField) => { diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index 4c5848b6..1378e97c 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -9,7 +9,7 @@ import { Result } from '../../src/utils/Result'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import SearchField from '../../src/utils/SearchField'; import { Settings } from '../../src/settings/reducers/settings'; -import { OrderableFields } from '../../src/tags/data/TagsListChildrenProps'; +import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps'; import SortingDropdown from '../../src/utils/SortingDropdown'; describe('', () => { @@ -98,7 +98,7 @@ describe('', () => { it('can update current order via orderByColumn from table component', () => { const wrapper = createWrapper({ filteredTags: [ 'foo', 'bar' ], stats: {} }); - const callOrderBy = (field: OrderableFields) => { + const callOrderBy = (field: TagsOrderableFields) => { ((wrapper.find(TagsTable).prop('orderByColumn') as Function)(field) as Function)(); }; From 6213067f35e8a122e75d99150d6fa498419e81fa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Dec 2021 10:26:38 +0100 Subject: [PATCH 27/49] Removed default export in SortingDropdown --- src/settings/ShortUrlsList.tsx | 2 +- src/settings/Tags.tsx | 2 +- src/short-urls/ShortUrlsList.tsx | 2 +- src/tags/TagsList.tsx | 2 +- src/utils/SortingDropdown.tsx | 2 +- src/visits/charts/SortableBarChartCard.tsx | 2 +- test/settings/ShortUrlsList.test.tsx | 2 +- test/settings/Tags.test.tsx | 2 +- test/short-urls/ShortUrlsList.test.tsx | 2 +- test/tags/TagsList.test.tsx | 2 +- test/utils/SortingDropdown.test.tsx | 2 +- test/visits/charts/SortableBarChartCard.test.tsx | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/settings/ShortUrlsList.tsx b/src/settings/ShortUrlsList.tsx index 57407afc..06ca899b 100644 --- a/src/settings/ShortUrlsList.tsx +++ b/src/settings/ShortUrlsList.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { FormGroup } from 'reactstrap'; -import SortingDropdown from '../utils/SortingDropdown'; +import { SortingDropdown } from '../utils/SortingDropdown'; import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data'; import { SimpleCard } from '../utils/SimpleCard'; import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from './reducers/settings'; diff --git a/src/settings/Tags.tsx b/src/settings/Tags.tsx index 6fbc2194..02289561 100644 --- a/src/settings/Tags.tsx +++ b/src/settings/Tags.tsx @@ -3,7 +3,7 @@ import { FormGroup } from 'reactstrap'; import { SimpleCard } from '../utils/SimpleCard'; import { TagsModeDropdown } from '../tags/TagsModeDropdown'; import { capitalize } from '../utils/utils'; -import SortingDropdown from '../utils/SortingDropdown'; +import { SortingDropdown } from '../utils/SortingDropdown'; import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; import { Settings, TagsSettings } from './reducers/settings'; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index a3722d38..b5887cb6 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -2,7 +2,7 @@ import { pipe } from 'ramda'; import { FC, useEffect, useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; -import SortingDropdown from '../utils/SortingDropdown'; +import { SortingDropdown } from '../utils/SortingDropdown'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { getServerId, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index bcc3934f..5076b7f9 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -10,7 +10,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError'; import { Topics } from '../mercure/helpers/Topics'; import { Settings, TagsMode } from '../settings/reducers/settings'; import { determineOrderDir, sortList } from '../utils/helpers/ordering'; -import SortingDropdown from '../utils/SortingDropdown'; +import { SortingDropdown } from '../utils/SortingDropdown'; import { TagsList as TagsListState } from './reducers/tagsList'; import { TagsOrderableFields, diff --git a/src/utils/SortingDropdown.tsx b/src/utils/SortingDropdown.tsx index ebc04d2c..bb41fdd3 100644 --- a/src/utils/SortingDropdown.tsx +++ b/src/utils/SortingDropdown.tsx @@ -14,7 +14,7 @@ export interface SortingDropdownProps { right?: boolean; } -export default function SortingDropdown( +export function SortingDropdown( { items, order, onChange, isButton = true, right = false }: SortingDropdownProps, ) { const handleItemClick = (fieldKey: T) => () => { diff --git a/src/visits/charts/SortableBarChartCard.tsx b/src/visits/charts/SortableBarChartCard.tsx index a627e514..da979a5f 100644 --- a/src/visits/charts/SortableBarChartCard.tsx +++ b/src/visits/charts/SortableBarChartCard.tsx @@ -4,7 +4,7 @@ import { rangeOf } from '../../utils/utils'; import { Order } from '../../utils/helpers/ordering'; import SimplePaginator from '../../common/SimplePaginator'; import { roundTen } from '../../utils/helpers/numbers'; -import SortingDropdown from '../../utils/SortingDropdown'; +import { SortingDropdown } from '../../utils/SortingDropdown'; import PaginationDropdown from '../../utils/PaginationDropdown'; import { Stats, StatsRow } from '../types'; import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart'; diff --git a/test/settings/ShortUrlsList.test.tsx b/test/settings/ShortUrlsList.test.tsx index 3c1136de..602f9586 100644 --- a/test/settings/ShortUrlsList.test.tsx +++ b/test/settings/ShortUrlsList.test.tsx @@ -2,7 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from '../../src/settings/reducers/settings'; import { ShortUrlsList } from '../../src/settings/ShortUrlsList'; -import SortingDropdown from '../../src/utils/SortingDropdown'; +import { SortingDropdown } from '../../src/utils/SortingDropdown'; import { ShortUrlsOrder } from '../../src/short-urls/data'; describe('', () => { diff --git a/test/settings/Tags.test.tsx b/test/settings/Tags.test.tsx index 8e5440a4..b745cd99 100644 --- a/test/settings/Tags.test.tsx +++ b/test/settings/Tags.test.tsx @@ -4,7 +4,7 @@ import { FormGroup } from 'reactstrap'; import { Settings, TagsMode, TagsSettings } from '../../src/settings/reducers/settings'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import { Tags } from '../../src/settings/Tags'; -import SortingDropdown from '../../src/utils/SortingDropdown'; +import { SortingDropdown } from '../../src/utils/SortingDropdown'; import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps'; describe('', () => { diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 81871a48..c87f15c9 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -7,7 +7,7 @@ import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList'; import { ShortUrlsOrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; -import SortingDropdown from '../../src/utils/SortingDropdown'; +import { SortingDropdown } from '../../src/utils/SortingDropdown'; import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index 1378e97c..e374cf3d 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -10,7 +10,7 @@ import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import SearchField from '../../src/utils/SearchField'; import { Settings } from '../../src/settings/reducers/settings'; import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps'; -import SortingDropdown from '../../src/utils/SortingDropdown'; +import { SortingDropdown } from '../../src/utils/SortingDropdown'; describe('', () => { let wrapper: ShallowWrapper; diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx index 934bbfb7..fdd4a3dd 100644 --- a/test/utils/SortingDropdown.test.tsx +++ b/test/utils/SortingDropdown.test.tsx @@ -3,7 +3,7 @@ import { DropdownItem, DropdownToggle } from 'reactstrap'; import { identity, values } from 'ramda'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons'; -import SortingDropdown, { SortingDropdownProps } from '../../src/utils/SortingDropdown'; +import { SortingDropdown , SortingDropdownProps } from '../../src/utils/SortingDropdown'; import { OrderDir } from '../../src/utils/helpers/ordering'; describe('', () => { diff --git a/test/visits/charts/SortableBarChartCard.test.tsx b/test/visits/charts/SortableBarChartCard.test.tsx index 338b220b..d8f7867d 100644 --- a/test/visits/charts/SortableBarChartCard.test.tsx +++ b/test/visits/charts/SortableBarChartCard.test.tsx @@ -1,6 +1,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { range } from 'ramda'; -import SortingDropdown from '../../../src/utils/SortingDropdown'; +import { SortingDropdown } from '../../../src/utils/SortingDropdown'; import PaginationDropdown from '../../../src/utils/PaginationDropdown'; import { rangeOf } from '../../../src/utils/utils'; import { OrderDir } from '../../../src/utils/helpers/ordering'; From 994f31b7e5cbddae3edff520c4be96c98175d0da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Dec 2021 10:31:48 +0100 Subject: [PATCH 28/49] Renamed SortingDropdown to OrderingDropdown, for consistency --- src/settings/ShortUrlsList.tsx | 4 +-- src/settings/Tags.tsx | 4 +-- src/short-urls/ShortUrlsList.tsx | 4 +-- src/tags/TagsList.tsx | 4 +-- src/utils/OrderingDropdown.scss | 8 +++++ ...rtingDropdown.tsx => OrderingDropdown.tsx} | 12 +++---- src/utils/SortingDropdown.scss | 8 ----- src/visits/charts/SortableBarChartCard.tsx | 4 +-- test/settings/ShortUrlsList.test.tsx | 6 ++-- test/settings/Tags.test.tsx | 6 ++-- test/short-urls/ShortUrlsList.test.tsx | 32 +++++++++---------- test/tags/TagsList.test.tsx | 12 +++---- test/utils/SortingDropdown.test.tsx | 6 ++-- .../charts/SortableBarChartCard.test.tsx | 4 +-- 14 files changed, 57 insertions(+), 57 deletions(-) create mode 100644 src/utils/OrderingDropdown.scss rename src/utils/{SortingDropdown.tsx => OrderingDropdown.tsx} (82%) delete mode 100644 src/utils/SortingDropdown.scss diff --git a/src/settings/ShortUrlsList.tsx b/src/settings/ShortUrlsList.tsx index 06ca899b..11dcb687 100644 --- a/src/settings/ShortUrlsList.tsx +++ b/src/settings/ShortUrlsList.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { FormGroup } from 'reactstrap'; -import { SortingDropdown } from '../utils/SortingDropdown'; +import { OrderingDropdown } from '../utils/OrderingDropdown'; import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data'; import { SimpleCard } from '../utils/SimpleCard'; import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from './reducers/settings'; @@ -14,7 +14,7 @@ export const ShortUrlsList: FC = ({ settings: { shortUrlsLis - setShortUrlsListSettings({ defaultOrdering: { field, dir } })} diff --git a/src/settings/Tags.tsx b/src/settings/Tags.tsx index 02289561..85103215 100644 --- a/src/settings/Tags.tsx +++ b/src/settings/Tags.tsx @@ -3,7 +3,7 @@ import { FormGroup } from 'reactstrap'; import { SimpleCard } from '../utils/SimpleCard'; import { TagsModeDropdown } from '../tags/TagsModeDropdown'; import { capitalize } from '../utils/utils'; -import { SortingDropdown } from '../utils/SortingDropdown'; +import { OrderingDropdown } from '../utils/OrderingDropdown'; import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; import { Settings, TagsSettings } from './reducers/settings'; @@ -25,7 +25,7 @@ export const Tags: FC = ({ settings: { tags }, setTagsSettings }) => - setTagsSettings({ ...tags, defaultOrdering: { field, dir } })} diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index b5887cb6..c0f12f6a 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -2,7 +2,7 @@ import { pipe } from 'ramda'; import { FC, useEffect, useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; -import { SortingDropdown } from '../utils/SortingDropdown'; +import { OrderingDropdown } from '../utils/OrderingDropdown'; import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { getServerId, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; @@ -64,7 +64,7 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = <>
- +
, TagsTable: FC
- setOrder({ field, dir })} diff --git a/src/utils/OrderingDropdown.scss b/src/utils/OrderingDropdown.scss new file mode 100644 index 00000000..253824b9 --- /dev/null +++ b/src/utils/OrderingDropdown.scss @@ -0,0 +1,8 @@ +.ordering-dropdown__menu--link.ordering-dropdown__menu--link { + min-width: 11rem; +} + +.ordering-dropdown__sort-icon { + margin: 3.5px 0 0; + float: right; +} diff --git a/src/utils/SortingDropdown.tsx b/src/utils/OrderingDropdown.tsx similarity index 82% rename from src/utils/SortingDropdown.tsx rename to src/utils/OrderingDropdown.tsx index bb41fdd3..8d112843 100644 --- a/src/utils/SortingDropdown.tsx +++ b/src/utils/OrderingDropdown.tsx @@ -4,9 +4,9 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons'; import classNames from 'classnames'; import { determineOrderDir, Order, OrderDir } from './helpers/ordering'; -import './SortingDropdown.scss'; +import './OrderingDropdown.scss'; -export interface SortingDropdownProps { +export interface OrderingDropdownProps { items: Record; order: Order; onChange: (orderField?: T, orderDir?: OrderDir) => void; @@ -14,8 +14,8 @@ export interface SortingDropdownProps { right?: boolean; } -export function SortingDropdown( - { items, order, onChange, isButton = true, right = false }: SortingDropdownProps, +export function OrderingDropdown( + { items, order, onChange, isButton = true, right = false }: OrderingDropdownProps, ) { const handleItemClick = (fieldKey: T) => () => { const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir); @@ -36,7 +36,7 @@ export function SortingDropdown( {toPairs(items).map(([ fieldKey, fieldValue ]) => ( @@ -44,7 +44,7 @@ export function SortingDropdown( {order.field === fieldKey && ( )} diff --git a/src/utils/SortingDropdown.scss b/src/utils/SortingDropdown.scss deleted file mode 100644 index cbb9bcbc..00000000 --- a/src/utils/SortingDropdown.scss +++ /dev/null @@ -1,8 +0,0 @@ -.sorting-dropdown__menu--link.sorting-dropdown__menu--link { - min-width: 11rem; -} - -.sorting-dropdown__sort-icon { - margin: 3.5px 0 0; - float: right; -} diff --git a/src/visits/charts/SortableBarChartCard.tsx b/src/visits/charts/SortableBarChartCard.tsx index da979a5f..b4788e7b 100644 --- a/src/visits/charts/SortableBarChartCard.tsx +++ b/src/visits/charts/SortableBarChartCard.tsx @@ -4,7 +4,7 @@ import { rangeOf } from '../../utils/utils'; import { Order } from '../../utils/helpers/ordering'; import SimplePaginator from '../../common/SimplePaginator'; import { roundTen } from '../../utils/helpers/numbers'; -import { SortingDropdown } from '../../utils/SortingDropdown'; +import { OrderingDropdown } from '../../utils/OrderingDropdown'; import PaginationDropdown from '../../utils/PaginationDropdown'; import { Stats, StatsRow } from '../types'; import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart'; @@ -96,7 +96,7 @@ export const SortableBarChartCard: FC = ({ <> {title}
- ', () => { @@ -27,7 +27,7 @@ describe('', () => { [{ defaultOrdering: { field: 'visits', dir: 'ASC' } as ShortUrlsOrder }, { field: 'visits', dir: 'ASC' }], ])('shows expected ordering', (shortUrlsList, expectedOrder) => { const wrapper = createWrapper(shortUrlsList); - const dropdown = wrapper.find(SortingDropdown); + const dropdown = wrapper.find(OrderingDropdown); expect(dropdown.prop('order')).toEqual(expectedOrder); }); @@ -39,7 +39,7 @@ describe('', () => { [ 'title', 'DESC' ], ])('invokes setSettings when ordering changes', (field, dir) => { const wrapper = createWrapper(); - const dropdown = wrapper.find(SortingDropdown); + const dropdown = wrapper.find(OrderingDropdown); expect(setSettings).not.toHaveBeenCalled(); dropdown.simulate('change', field, dir); diff --git a/test/settings/Tags.test.tsx b/test/settings/Tags.test.tsx index b745cd99..a99bd1a4 100644 --- a/test/settings/Tags.test.tsx +++ b/test/settings/Tags.test.tsx @@ -4,7 +4,7 @@ import { FormGroup } from 'reactstrap'; import { Settings, TagsMode, TagsSettings } from '../../src/settings/reducers/settings'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import { Tags } from '../../src/settings/Tags'; -import { SortingDropdown } from '../../src/utils/SortingDropdown'; +import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps'; describe('', () => { @@ -60,7 +60,7 @@ describe('', () => { [{ defaultOrdering: { field: 'visits', dir: 'ASC' } as TagsOrder }, { field: 'visits', dir: 'ASC' }], ])('shows expected ordering', (tags, expectedOrder) => { const wrapper = createWrapper(tags); - const dropdown = wrapper.find(SortingDropdown); + const dropdown = wrapper.find(OrderingDropdown); expect(dropdown.prop('order')).toEqual(expectedOrder); }); @@ -72,7 +72,7 @@ describe('', () => { [ 'shortUrls', 'DESC' ], ])('invokes setTagsSettings when ordering changes', (field, dir) => { const wrapper = createWrapper(); - const dropdown = wrapper.find(SortingDropdown); + const dropdown = wrapper.find(OrderingDropdown); expect(setTagsSettings).not.toHaveBeenCalled(); dropdown.simulate('change', field, dir); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index c87f15c9..1df03e6a 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -7,7 +7,7 @@ import shortUrlsListCreator from '../../src/short-urls/ShortUrlsList'; import { ShortUrlsOrderableFields, ShortUrl, ShortUrlsOrder } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; -import { SortingDropdown } from '../../src/utils/SortingDropdown'; +import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; @@ -54,7 +54,7 @@ describe('', () => { it('wraps expected components', () => { expect(wrapper.find(ShortUrlsTable)).toHaveLength(1); - expect(wrapper.find(SortingDropdown)).toHaveLength(1); + expect(wrapper.find(OrderingDropdown)).toHaveLength(1); expect(wrapper.find(Paginator)).toHaveLength(1); expect(wrapper.find(SearchBar)).toHaveLength(1); }); @@ -80,39 +80,39 @@ describe('', () => { expect(renderIcon('visits').props.currentOrder).toEqual({}); - wrapper.find(SortingDropdown).simulate('change', 'visits'); + wrapper.find(OrderingDropdown).simulate('change', 'visits'); expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits' }); - wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC'); + wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); expect(renderIcon('visits').props.currentOrder).toEqual({ field: 'visits', dir: 'ASC' }); }); it('handles order through table', () => { const orderByColumn: (field: ShortUrlsOrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); orderByColumn('visits')(); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); orderByColumn('title')(); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'title', dir: 'ASC' }); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'title', dir: 'ASC' }); orderByColumn('shortCode')(); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); }); it('handles order through dropdown', () => { - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); - wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC'); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); + wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); - wrapper.find(SortingDropdown).simulate('change', 'shortCode', 'DESC'); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'DESC' }); + wrapper.find(OrderingDropdown).simulate('change', 'shortCode', 'DESC'); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'DESC' }); - wrapper.find(SortingDropdown).simulate('change', undefined, undefined); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); + wrapper.find(OrderingDropdown).simulate('change', undefined, undefined); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); }); it.each([ @@ -122,6 +122,6 @@ describe('', () => { ])('has expected initial ordering', (initialOrderBy, field, dir) => { const wrapper = createWrapper(initialOrderBy); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field, dir }); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field, dir }); }); }); diff --git a/test/tags/TagsList.test.tsx b/test/tags/TagsList.test.tsx index e374cf3d..1d6de35b 100644 --- a/test/tags/TagsList.test.tsx +++ b/test/tags/TagsList.test.tsx @@ -10,7 +10,7 @@ import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import SearchField from '../../src/utils/SearchField'; import { Settings } from '../../src/settings/reducers/settings'; import { TagsOrderableFields } from '../../src/tags/data/TagsListChildrenProps'; -import { SortingDropdown } from '../../src/utils/SortingDropdown'; +import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; describe('', () => { let wrapper: ShallowWrapper; @@ -89,11 +89,11 @@ describe('', () => { it('triggers ordering when sorting dropdown changes', () => { const wrapper = createWrapper({ filteredTags: [] }); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); - wrapper.find(SortingDropdown).simulate('change', 'tag', 'DESC'); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'tag', dir: 'DESC' }); - wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC'); - expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({}); + wrapper.find(OrderingDropdown).simulate('change', 'tag', 'DESC'); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'tag', dir: 'DESC' }); + wrapper.find(OrderingDropdown).simulate('change', 'visits', 'ASC'); + expect(wrapper.find(OrderingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); }); it('can update current order via orderByColumn from table component', () => { diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx index fdd4a3dd..a2c874ce 100644 --- a/test/utils/SortingDropdown.test.tsx +++ b/test/utils/SortingDropdown.test.tsx @@ -3,7 +3,7 @@ import { DropdownItem, DropdownToggle } from 'reactstrap'; import { identity, values } from 'ramda'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons'; -import { SortingDropdown , SortingDropdownProps } from '../../src/utils/SortingDropdown'; +import { OrderingDropdown, OrderingDropdownProps } from '../../src/utils/OrderingDropdown'; import { OrderDir } from '../../src/utils/helpers/ordering'; describe('', () => { @@ -13,8 +13,8 @@ describe('', () => { bar: 'Bar', baz: 'Hello World', }; - const createWrapper = (props: Partial = {}) => { - wrapper = shallow(); + const createWrapper = (props: Partial = {}) => { + wrapper = shallow(); return wrapper; }; diff --git a/test/visits/charts/SortableBarChartCard.test.tsx b/test/visits/charts/SortableBarChartCard.test.tsx index d8f7867d..91d088c5 100644 --- a/test/visits/charts/SortableBarChartCard.test.tsx +++ b/test/visits/charts/SortableBarChartCard.test.tsx @@ -1,6 +1,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { range } from 'ramda'; -import { SortingDropdown } from '../../../src/utils/SortingDropdown'; +import { OrderingDropdown } from '../../../src/utils/OrderingDropdown'; import PaginationDropdown from '../../../src/utils/PaginationDropdown'; import { rangeOf } from '../../../src/utils/utils'; import { OrderDir } from '../../../src/utils/helpers/ordering'; @@ -45,7 +45,7 @@ describe('', () => { beforeEach(() => { const wrapper = createWrapper(); - const dropdown = wrapper.renderProp('title' as never)().find(SortingDropdown); + const dropdown = wrapper.renderProp('title' as never)().find(OrderingDropdown); assert = (sortName: string, sortDir: OrderDir, keys: string[], values: number[]) => { dropdown.prop('onChange')(sortName, sortDir); From dbf4b0926ec5f17cc6139da7d30079aac9f93062 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Dec 2021 10:49:12 +0100 Subject: [PATCH 29/49] Added Settings suffix to all settings sub-components --- ...pdates.tsx => RealTimeUpdatesSettings.tsx} | 4 +- ...ation.tsx => ShortUrlCreationSettings.tsx} | 8 ++-- ...UrlsList.tsx => ShortUrlsListSettings.tsx} | 8 ++-- src/settings/{Tags.tsx => TagsSettings.tsx} | 6 +-- ...erface.scss => UserInterfaceSettings.scss} | 0 ...nterface.tsx => UserInterfaceSettings.tsx} | 4 +- .../{Visits.tsx => VisitsSettings.tsx} | 6 +-- src/settings/services/provideServices.ts | 44 +++++++++---------- ...t.tsx => RealTimeUpdatesSettings.test.tsx} | 13 +++--- ....tsx => ShortUrlCreationSettings.test.tsx} | 14 +++--- ...est.tsx => ShortUrlsListSettings.test.tsx} | 14 +++--- .../{Tags.test.tsx => TagsSettings.test.tsx} | 10 ++--- ...est.tsx => UserInterfaceSettings.test.tsx} | 6 +-- ...isits.test.tsx => VisitsSettings.test.tsx} | 6 +-- 14 files changed, 76 insertions(+), 67 deletions(-) rename src/settings/{RealTimeUpdates.tsx => RealTimeUpdatesSettings.tsx} (96%) rename src/settings/{ShortUrlCreation.tsx => ShortUrlCreationSettings.tsx} (88%) rename src/settings/{ShortUrlsList.tsx => ShortUrlsListSettings.tsx} (75%) rename src/settings/{Tags.tsx => TagsSettings.tsx} (83%) rename src/settings/{UserInterface.scss => UserInterfaceSettings.scss} (100%) rename src/settings/{UserInterface.tsx => UserInterfaceSettings.tsx} (88%) rename src/settings/{Visits.tsx => VisitsSettings.tsx} (72%) rename test/settings/{RealTimeUpdates.test.tsx => RealTimeUpdatesSettings.test.tsx} (91%) rename test/settings/{ShortUrlCreation.test.tsx => ShortUrlCreationSettings.test.tsx} (89%) rename test/settings/{ShortUrlsList.test.tsx => ShortUrlsListSettings.test.tsx} (76%) rename test/settings/{Tags.test.tsx => TagsSettings.test.tsx} (87%) rename test/settings/{UserInterface.test.tsx => UserInterfaceSettings.test.tsx} (87%) rename test/settings/{Visits.test.tsx => VisitsSettings.test.tsx} (90%) diff --git a/src/settings/RealTimeUpdates.tsx b/src/settings/RealTimeUpdatesSettings.tsx similarity index 96% rename from src/settings/RealTimeUpdates.tsx rename to src/settings/RealTimeUpdatesSettings.tsx index 243a7d0c..8081b26f 100644 --- a/src/settings/RealTimeUpdates.tsx +++ b/src/settings/RealTimeUpdatesSettings.tsx @@ -12,7 +12,7 @@ interface RealTimeUpdatesProps { const intervalValue = (interval?: number) => !interval ? '' : `${interval}`; -const RealTimeUpdates = ( +const RealTimeUpdatesSettings = ( { settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps, ) => ( @@ -50,4 +50,4 @@ const RealTimeUpdates = ( ); -export default RealTimeUpdates; +export default RealTimeUpdatesSettings; diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreationSettings.tsx similarity index 88% rename from src/settings/ShortUrlCreation.tsx rename to src/settings/ShortUrlCreationSettings.tsx index 53814d71..5c3a1ebb 100644 --- a/src/settings/ShortUrlCreation.tsx +++ b/src/settings/ShortUrlCreationSettings.tsx @@ -3,11 +3,11 @@ import { DropdownItem, FormGroup } from 'reactstrap'; import { SimpleCard } from '../utils/SimpleCard'; import ToggleSwitch from '../utils/ToggleSwitch'; import { DropdownBtn } from '../utils/DropdownBtn'; -import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings'; +import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings'; interface ShortUrlCreationProps { settings: Settings; - setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void; + setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void; } const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string => @@ -17,8 +17,8 @@ const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): R ? <>The list of suggested tags will contain those including provided input. : <>The list of suggested tags will contain those starting with provided input.; -export const ShortUrlCreation: FC = ({ settings, setShortUrlCreationSettings }) => { - const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false }; +export const ShortUrlCreationSettings: FC = ({ settings, setShortUrlCreationSettings }) => { + const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false }; const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings( { ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode }, ); diff --git a/src/settings/ShortUrlsList.tsx b/src/settings/ShortUrlsListSettings.tsx similarity index 75% rename from src/settings/ShortUrlsList.tsx rename to src/settings/ShortUrlsListSettings.tsx index 11dcb687..01ed2547 100644 --- a/src/settings/ShortUrlsList.tsx +++ b/src/settings/ShortUrlsListSettings.tsx @@ -3,14 +3,16 @@ import { FormGroup } from 'reactstrap'; import { OrderingDropdown } from '../utils/OrderingDropdown'; import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data'; import { SimpleCard } from '../utils/SimpleCard'; -import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from './reducers/settings'; +import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings'; interface ShortUrlsListProps { settings: Settings; - setShortUrlsListSettings: (settings: ShortUrlsListSettings) => void; + setShortUrlsListSettings: (settings: ShortUrlsSettings) => void; } -export const ShortUrlsList: FC = ({ settings: { shortUrlsList }, setShortUrlsListSettings }) => ( +export const ShortUrlsListSettings: FC = ( + { settings: { shortUrlsList }, setShortUrlsListSettings }, +) => ( diff --git a/src/settings/Tags.tsx b/src/settings/TagsSettings.tsx similarity index 83% rename from src/settings/Tags.tsx rename to src/settings/TagsSettings.tsx index 85103215..7359fb2f 100644 --- a/src/settings/Tags.tsx +++ b/src/settings/TagsSettings.tsx @@ -5,14 +5,14 @@ import { TagsModeDropdown } from '../tags/TagsModeDropdown'; import { capitalize } from '../utils/utils'; import { OrderingDropdown } from '../utils/OrderingDropdown'; import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps'; -import { Settings, TagsSettings } from './reducers/settings'; +import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings'; interface TagsProps { settings: Settings; - setTagsSettings: (settings: TagsSettings) => void; + setTagsSettings: (settings: TagsSettingsOptions) => void; } -export const Tags: FC = ({ settings: { tags }, setTagsSettings }) => ( +export const TagsSettings: FC = ({ settings: { tags }, setTagsSettings }) => ( diff --git a/src/settings/UserInterface.scss b/src/settings/UserInterfaceSettings.scss similarity index 100% rename from src/settings/UserInterface.scss rename to src/settings/UserInterfaceSettings.scss diff --git a/src/settings/UserInterface.tsx b/src/settings/UserInterfaceSettings.tsx similarity index 88% rename from src/settings/UserInterface.tsx rename to src/settings/UserInterfaceSettings.tsx index e2d74a5d..cca9e11b 100644 --- a/src/settings/UserInterface.tsx +++ b/src/settings/UserInterfaceSettings.tsx @@ -6,14 +6,14 @@ import { SimpleCard } from '../utils/SimpleCard'; import ToggleSwitch from '../utils/ToggleSwitch'; import { changeThemeInMarkup, Theme } from '../utils/theme'; import { Settings, UiSettings } from './reducers/settings'; -import './UserInterface.scss'; +import './UserInterfaceSettings.scss'; interface UserInterfaceProps { settings: Settings; setUiSettings: (settings: UiSettings) => void; } -export const UserInterface: FC = ({ settings: { ui }, setUiSettings }) => ( +export const UserInterfaceSettings: FC = ({ settings: { ui }, setUiSettings }) => ( diff --git a/src/settings/Visits.tsx b/src/settings/VisitsSettings.tsx similarity index 72% rename from src/settings/Visits.tsx rename to src/settings/VisitsSettings.tsx index a64a9641..3bb58e88 100644 --- a/src/settings/Visits.tsx +++ b/src/settings/VisitsSettings.tsx @@ -2,14 +2,14 @@ import { FormGroup } from 'reactstrap'; import { FC } from 'react'; import { SimpleCard } from '../utils/SimpleCard'; import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector'; -import { Settings, VisitsSettings } from './reducers/settings'; +import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings'; interface VisitsProps { settings: Settings; - setVisitsSettings: (settings: VisitsSettings) => void; + setVisitsSettings: (settings: VisitsSettingsConfig) => void; } -export const Visits: FC = ({ settings, setVisitsSettings }) => ( +export const VisitsSettings: FC = ({ settings, setVisitsSettings }) => ( diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts index c54d37aa..d60ad3d7 100644 --- a/src/settings/services/provideServices.ts +++ b/src/settings/services/provideServices.ts @@ -1,5 +1,5 @@ import Bottle from 'bottlejs'; -import RealTimeUpdates from '../RealTimeUpdates'; +import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings'; import Settings from '../Settings'; import { setRealTimeUpdatesInterval, @@ -12,46 +12,46 @@ import { } from '../reducers/settings'; import { ConnectDecorator } from '../../container/types'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; -import { ShortUrlCreation } from '../ShortUrlCreation'; -import { UserInterface } from '../UserInterface'; -import { Visits } from '../Visits'; -import { Tags } from '../Tags'; -import { ShortUrlsList } from '../ShortUrlsList'; +import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings'; +import { UserInterfaceSettings } from '../UserInterfaceSettings'; +import { VisitsSettings } from '../VisitsSettings'; +import { TagsSettings } from '../TagsSettings'; +import { ShortUrlsListSettings } from '../ShortUrlsListSettings'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory( 'Settings', Settings, - 'RealTimeUpdates', - 'ShortUrlCreation', + 'RealTimeUpdatesSettings', + 'ShortUrlCreationSettings', 'ShortUrlsListSettings', - 'UserInterface', - 'Visits', - 'Tags', + 'UserInterfaceSettings', + 'VisitsSettings', + 'TagsSettings', ); bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); - bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates); + bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings); bottle.decorator( - 'RealTimeUpdates', + 'RealTimeUpdatesSettings', connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]), ); - bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation); - bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ])); + bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings); + bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ])); - bottle.serviceFactory('UserInterface', () => UserInterface); - bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ])); + bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings); + bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ])); - bottle.serviceFactory('Visits', () => Visits); - bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ])); + bottle.serviceFactory('VisitsSettings', () => VisitsSettings); + bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ])); - bottle.serviceFactory('Tags', () => Tags); - bottle.decorator('Tags', connect([ 'settings' ], [ 'setTagsSettings' ])); + bottle.serviceFactory('TagsSettings', () => TagsSettings); + bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ])); - bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsList); + bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings); bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ])); // Actions diff --git a/test/settings/RealTimeUpdates.test.tsx b/test/settings/RealTimeUpdatesSettings.test.tsx similarity index 91% rename from test/settings/RealTimeUpdates.test.tsx rename to test/settings/RealTimeUpdatesSettings.test.tsx index f8dd3cfe..17ea87f4 100644 --- a/test/settings/RealTimeUpdates.test.tsx +++ b/test/settings/RealTimeUpdatesSettings.test.tsx @@ -1,19 +1,22 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import { Input } from 'reactstrap'; -import { RealTimeUpdatesSettings, Settings } from '../../src/settings/reducers/settings'; -import RealTimeUpdates from '../../src/settings/RealTimeUpdates'; +import { + RealTimeUpdatesSettings as RealTimeUpdatesSettingsOptions, + Settings, +} from '../../src/settings/reducers/settings'; +import RealTimeUpdatesSettings from '../../src/settings/RealTimeUpdatesSettings'; import ToggleSwitch from '../../src/utils/ToggleSwitch'; -describe('', () => { +describe('', () => { const toggleRealTimeUpdates = jest.fn(); const setRealTimeUpdatesInterval = jest.fn(); let wrapper: ShallowWrapper; - const createWrapper = (realTimeUpdates: Partial = {}) => { + const createWrapper = (realTimeUpdates: Partial = {}) => { const settings = Mock.of({ realTimeUpdates }); wrapper = shallow( - ', () => { +describe('', () => { let wrapper: ShallowWrapper; const setShortUrlCreationSettings = jest.fn(); - const createWrapper = (shortUrlCreation?: ShortUrlCreationSettings) => { + const createWrapper = (shortUrlCreation?: ShortUrlsSettings) => { wrapper = shallow( - ({ shortUrlCreation })} setShortUrlCreationSettings={setShortUrlCreationSettings} />, @@ -68,9 +68,9 @@ describe('', () => { }); it.each([ - [ { tagFilteringMode: 'includes' } as ShortUrlCreationSettings, 'Suggest tags including input', 'including' ], + [ { tagFilteringMode: 'includes' } as ShortUrlsSettings, 'Suggest tags including input', 'including' ], [ - { tagFilteringMode: 'startsWith' } as ShortUrlCreationSettings, + { tagFilteringMode: 'startsWith' } as ShortUrlsSettings, 'Suggest tags starting with input', 'starting with', ], diff --git a/test/settings/ShortUrlsList.test.tsx b/test/settings/ShortUrlsListSettings.test.tsx similarity index 76% rename from test/settings/ShortUrlsList.test.tsx rename to test/settings/ShortUrlsListSettings.test.tsx index 89d999ca..f8ca25e7 100644 --- a/test/settings/ShortUrlsList.test.tsx +++ b/test/settings/ShortUrlsListSettings.test.tsx @@ -1,16 +1,20 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; -import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings } from '../../src/settings/reducers/settings'; -import { ShortUrlsList } from '../../src/settings/ShortUrlsList'; +import { + DEFAULT_SHORT_URLS_ORDERING, + Settings, + ShortUrlsListSettings as ShortUrlsSettings, +} from '../../src/settings/reducers/settings'; +import { ShortUrlsListSettings } from '../../src/settings/ShortUrlsListSettings'; import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; import { ShortUrlsOrder } from '../../src/short-urls/data'; -describe('', () => { +describe('', () => { let wrapper: ShallowWrapper; const setSettings = jest.fn(); - const createWrapper = (shortUrlsList?: ShortUrlsListSettings) => { + const createWrapper = (shortUrlsList?: ShortUrlsSettings) => { wrapper = shallow( - ({ shortUrlsList })} setShortUrlsListSettings={setSettings} />, + ({ shortUrlsList })} setShortUrlsListSettings={setSettings} />, ); return wrapper; diff --git a/test/settings/Tags.test.tsx b/test/settings/TagsSettings.test.tsx similarity index 87% rename from test/settings/Tags.test.tsx rename to test/settings/TagsSettings.test.tsx index a99bd1a4..d6ae737b 100644 --- a/test/settings/Tags.test.tsx +++ b/test/settings/TagsSettings.test.tsx @@ -1,17 +1,17 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import { FormGroup } from 'reactstrap'; -import { Settings, TagsMode, TagsSettings } from '../../src/settings/reducers/settings'; +import { Settings, TagsMode, TagsSettings as TagsSettingsOptions } from '../../src/settings/reducers/settings'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; -import { Tags } from '../../src/settings/Tags'; +import { TagsSettings } from '../../src/settings/TagsSettings'; import { OrderingDropdown } from '../../src/utils/OrderingDropdown'; import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps'; -describe('', () => { +describe('', () => { let wrapper: ShallowWrapper; const setTagsSettings = jest.fn(); - const createWrapper = (tags?: TagsSettings) => { - wrapper = shallow(({ tags })} setTagsSettings={setTagsSettings} />); + const createWrapper = (tags?: TagsSettingsOptions) => { + wrapper = shallow(({ tags })} setTagsSettings={setTagsSettings} />); return wrapper; }; diff --git a/test/settings/UserInterface.test.tsx b/test/settings/UserInterfaceSettings.test.tsx similarity index 87% rename from test/settings/UserInterface.test.tsx rename to test/settings/UserInterfaceSettings.test.tsx index 55abf685..03391cee 100644 --- a/test/settings/UserInterface.test.tsx +++ b/test/settings/UserInterfaceSettings.test.tsx @@ -3,15 +3,15 @@ import { Mock } from 'ts-mockery'; import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Settings, UiSettings } from '../../src/settings/reducers/settings'; -import { UserInterface } from '../../src/settings/UserInterface'; +import { UserInterfaceSettings } from '../../src/settings/UserInterfaceSettings'; import ToggleSwitch from '../../src/utils/ToggleSwitch'; import { Theme } from '../../src/utils/theme'; -describe('', () => { +describe('', () => { let wrapper: ShallowWrapper; const setUiSettings = jest.fn(); const createWrapper = (ui?: UiSettings) => { - wrapper = shallow(({ ui })} setUiSettings={setUiSettings} />); + wrapper = shallow(({ ui })} setUiSettings={setUiSettings} />); return wrapper; }; diff --git a/test/settings/Visits.test.tsx b/test/settings/VisitsSettings.test.tsx similarity index 90% rename from test/settings/Visits.test.tsx rename to test/settings/VisitsSettings.test.tsx index 89a73546..243fe3b6 100644 --- a/test/settings/Visits.test.tsx +++ b/test/settings/VisitsSettings.test.tsx @@ -1,15 +1,15 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import { Settings } from '../../src/settings/reducers/settings'; -import { Visits } from '../../src/settings/Visits'; +import { VisitsSettings } from '../../src/settings/VisitsSettings'; import { SimpleCard } from '../../src/utils/SimpleCard'; import { DateIntervalSelector } from '../../src/utils/dates/DateIntervalSelector'; -describe('', () => { +describe('', () => { let wrapper: ShallowWrapper; const setVisitsSettings = jest.fn(); const createWrapper = (settings: Partial = {}) => { - wrapper = shallow((settings)} setVisitsSettings={setVisitsSettings} />); + wrapper = shallow((settings)} setVisitsSettings={setVisitsSettings} />); return wrapper; }; From 49c841ca07f4f25cb9d1b3706d563fd303e351df Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Dec 2021 19:51:25 +0100 Subject: [PATCH 30/49] Added short URLs orderBy handling to the query state --- src/api/services/ShlinkApiClient.ts | 7 +--- src/short-urls/SearchBar.scss | 3 -- src/short-urls/ShortUrlsFilteringBar.scss | 3 ++ ...earchBar.tsx => ShortUrlsFilteringBar.tsx} | 14 +++---- src/short-urls/ShortUrlsList.tsx | 32 +++++++++------- src/short-urls/helpers/hooks.ts | 37 +++++++++++++++---- src/short-urls/services/provideServices.ts | 8 ++-- src/utils/helpers/ordering.ts | 9 +++++ test/servers/ManageServers.test.tsx | 8 ++-- test/short-urls/SearchBar.test.tsx | 10 ++--- test/short-urls/ShortUrlsList.test.tsx | 7 ++-- test/utils/helpers/ordering.test.ts | 8 ++++ 12 files changed, 94 insertions(+), 52 deletions(-) delete mode 100644 src/short-urls/SearchBar.scss create mode 100644 src/short-urls/ShortUrlsFilteringBar.scss rename src/short-urls/{SearchBar.tsx => ShortUrlsFilteringBar.tsx} (82%) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 330bfb55..a9906302 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -19,17 +19,14 @@ import { ShlinkShortUrlsListNormalizedParams, } from '../types'; import { stringifyQuery } from '../../utils/helpers/query'; +import { orderToString } from '../../utils/helpers/ordering'; const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; const rejectNilProps = reject(isNil); const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const { orderBy = {}, ...rest } = params; - const { field, dir } = orderBy; - return !dir ? rest : { - ...rest, - orderBy: `${field}-${dir}`, - }; + return { ...rest, orderBy: orderToString(orderBy) }; }; export default class ShlinkApiClient { diff --git a/src/short-urls/SearchBar.scss b/src/short-urls/SearchBar.scss deleted file mode 100644 index 3a3c64c1..00000000 --- a/src/short-urls/SearchBar.scss +++ /dev/null @@ -1,3 +0,0 @@ -.search-bar__tags-icon { - vertical-align: bottom; -} diff --git a/src/short-urls/ShortUrlsFilteringBar.scss b/src/short-urls/ShortUrlsFilteringBar.scss new file mode 100644 index 00000000..905210fd --- /dev/null +++ b/src/short-urls/ShortUrlsFilteringBar.scss @@ -0,0 +1,3 @@ +.short-urls-filtering-bar__tags-icon { + vertical-align: bottom; +} diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx similarity index 82% rename from src/short-urls/SearchBar.tsx rename to src/short-urls/ShortUrlsFilteringBar.tsx index be650422..e99a9282 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -10,13 +10,13 @@ import { formatIsoDate } from '../utils/helpers/date'; import ColorGenerator from '../utils/services/ColorGenerator'; import { DateRange } from '../utils/dates/types'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; -import './SearchBar.scss'; +import './ShortUrlsFilteringBar.scss'; -export type SearchBarProps = RouteChildrenProps; +export type ShortUrlsFilteringProps = RouteChildrenProps; const dateOrNull = (date?: string) => date ? parseISO(date) : null; -const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => { +const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortUrlsFilteringProps) => { const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props); const selectedTags = tags?.split(',') ?? []; const setDates = pipe( @@ -37,7 +37,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => ); return ( -
+
@@ -56,8 +56,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) =>
{selectedTags.length > 0 && ( -

- +

+   {selectedTags.map((tag) => removeTag(tag)} />)} @@ -67,4 +67,4 @@ const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => ); }; -export default SearchBar; +export default ShortUrlsFilteringBar; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index c0f12f6a..73eae8c3 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -14,7 +14,7 @@ import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; -import { ShortUrlsOrderableFields, ShortUrlsOrder, SHORT_URLS_ORDERABLE_FIELDS } from './data'; +import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data'; interface ShortUrlsListProps extends RouteComponentProps { selectedServer: SelectedServer; @@ -23,7 +23,7 @@ interface ShortUrlsListProps extends RouteComponentProps, SearchBar: FC) => boundToMercureHub(({ +const ShortUrlsList = (ShortUrlsTable: FC, ShortUrlsFilteringBar: FC) => boundToMercureHub(({ listShortUrls, match, location, @@ -33,16 +33,21 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = settings, }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); - const initialOrderBy = settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; - const [ order, setOrder ] = useState(initialOrderBy); - const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); + const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); + const [ actualOrderBy, setActualOrderBy ] = useState( + // This separated state handling is needed to be able to fall back to settings value, but only once when loaded + orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING, + ); const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); const { pagination } = shortUrlsList?.shortUrls ?? {}; - - const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => setOrder({ field, dir }); + const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => { + toFirstPage({ orderBy: { field, dir } }); + setActualOrderBy({ field, dir }); + }; const orderByColumn = (field: ShortUrlsOrderableFields) => () => - handleOrderBy(field, determineOrderDir(field, order.field, order.dir)); - const renderOrderIcon = (field: ShortUrlsOrderableFields) => ; + handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir)); + const renderOrderIcon = (field: ShortUrlsOrderableFields) => + ; const addTag = pipe( (newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','), (tags) => toFirstPage({ tags }), @@ -53,18 +58,17 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = page: match.params.page, searchTerm: search, tags: selectedTags, - itemsPerPage: undefined, startDate, endDate, - orderBy: order, + orderBy: actualOrderBy, }); - }, [ match.params.page, search, selectedTags, startDate, endDate, order ]); + }, [ match.params.page, search, selectedTags, startDate, endDate, actualOrderBy ]); return ( <> -
+
- +
; -type ToFirstPage = (extra: Partial) => void; +type ToFirstPage = (extra: Partial) => void; export interface ShortUrlListRouteParams { page: string; serverId: string; } -interface ShortUrlsQuery { +interface ShortUrlsQueryCommon { tags?: string; search?: string; startDate?: string; endDate?: string; } -export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => { - const query = useMemo(() => parseQuery(location.search), [ location ]); - const toFirstPageWithExtra = (extra: Partial) => { - const evolvedQuery = stringifyQuery({ ...query, ...extra }); +interface ShortUrlsQuery extends ShortUrlsQueryCommon { + orderBy?: string; +} + +interface ShortUrlsFiltering extends ShortUrlsQueryCommon { + orderBy?: ShortUrlsOrder; +} + +export const useShortUrlsQuery = ( + { history, location, match }: ServerIdRouteProps, +): [ShortUrlsFiltering, ToFirstPage] => { + const query = useMemo( + pipe( + () => parseQuery(location.search), + ({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : { + ...rest, + orderBy: stringToOrder(orderBy), + }, + ), + [ location.search ], + ); + const toFirstPageWithExtra = (extra: Partial) => { + const { orderBy, ...mergedQuery } = { ...query, ...extra }; + const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) }; + const evolvedQuery = stringifyQuery(normalizedQuery); const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`; history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`); diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 45b745f2..783234fa 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -1,5 +1,5 @@ import Bottle, { Decorator } from 'bottlejs'; -import SearchBar from '../SearchBar'; +import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar'; import ShortUrlsList from '../ShortUrlsList'; import ShortUrlsRow from '../helpers/ShortUrlsRow'; import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu'; @@ -19,7 +19,7 @@ import { getShortUrlDetail } from '../reducers/shortUrlDetail'; const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => { // Components - bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar'); + bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar'); bottle.decorator('ShortUrlsList', connect( [ 'selectedServer', 'mercureInfo', 'shortUrlsList', 'settings' ], [ 'listShortUrls', 'createNewVisits', 'loadMercureInfo' ], @@ -50,8 +50,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); // Services - bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator'); - bottle.decorator('SearchBar', withRouter); + bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator'); + bottle.decorator('ShortUrlsFilteringBar', withRouter); // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); diff --git a/src/utils/helpers/ordering.ts b/src/utils/helpers/ordering.ts index f3f19cf3..478cdeed 100644 --- a/src/utils/helpers/ordering.ts +++ b/src/utils/helpers/ordering.ts @@ -30,3 +30,12 @@ export const sortList = (list: List[], { field, dir }: Order b[field] ? greaterThan : smallerThan; }); + +export const orderToString = (order: Order): string | undefined => + order.dir ? `${order.field}-${order.dir}` : undefined; + +export const stringToOrder = (order: string): Order => { + const [ field, dir ] = order.split('-') as [ T | undefined, OrderDir | undefined ]; + + return { field, dir }; +}; diff --git a/test/servers/ManageServers.test.tsx b/test/servers/ManageServers.test.tsx index c860bb9e..3db3bec2 100644 --- a/test/servers/ManageServers.test.tsx +++ b/test/servers/ManageServers.test.tsx @@ -33,20 +33,20 @@ describe('', () => { bar: createServerMock('bar'), baz: createServerMock('baz'), }); - const searchBar = wrapper.find(SearchField); + const searchField = wrapper.find(SearchField); expect(wrapper.find(ManageServersRow)).toHaveLength(3); expect(wrapper.find('tbody').find('tr')).toHaveLength(0); - searchBar.simulate('change', 'foo'); + searchField.simulate('change', 'foo'); expect(wrapper.find(ManageServersRow)).toHaveLength(1); expect(wrapper.find('tbody').find('tr')).toHaveLength(0); - searchBar.simulate('change', 'ba'); + searchField.simulate('change', 'ba'); expect(wrapper.find(ManageServersRow)).toHaveLength(2); expect(wrapper.find('tbody').find('tr')).toHaveLength(0); - searchBar.simulate('change', 'invalid'); + searchField.simulate('change', 'invalid'); expect(wrapper.find(ManageServersRow)).toHaveLength(0); expect(wrapper.find('tbody').find('tr')).toHaveLength(1); }); diff --git a/test/short-urls/SearchBar.test.tsx b/test/short-urls/SearchBar.test.tsx index c4697abe..b8ccdc4c 100644 --- a/test/short-urls/SearchBar.test.tsx +++ b/test/short-urls/SearchBar.test.tsx @@ -3,21 +3,21 @@ import { Mock } from 'ts-mockery'; import { History, Location } from 'history'; import { match } from 'react-router'; import { formatISO } from 'date-fns'; -import searchBarCreator, { SearchBarProps } from '../../src/short-urls/SearchBar'; +import filteringBarCreator, { ShortUrlsFilteringProps } from '../../src/short-urls/ShortUrlsFilteringBar'; import SearchField from '../../src/utils/SearchField'; import Tag from '../../src/tags/helpers/Tag'; import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; -describe('', () => { +describe('', () => { let wrapper: ShallowWrapper; - const SearchBar = searchBarCreator(Mock.all()); + const ShortUrlsFilteringBar = filteringBarCreator(Mock.all()); const push = jest.fn(); const now = new Date(); - const createWrapper = (props: Partial = {}) => { + const createWrapper = (props: Partial = {}) => { wrapper = shallow( - ({ push })} location={Mock.of({ search: '' })} match={Mock.of>({ params: { serverId: '1' } })} diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index 1df03e6a..da6bfec3 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -12,11 +12,12 @@ import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; import { Settings } from '../../src/settings/reducers/settings'; +import ShortUrlsFilteringBar from '../../src/short-urls/ShortUrlsFilteringBar'; describe('', () => { let wrapper: ShallowWrapper; const ShortUrlsTable = () => null; - const SearchBar = () => null; + const ShortUrlsFilteringBar = () => null; const listShortUrlsMock = jest.fn(); const push = jest.fn(); const shortUrlsList = Mock.of({ @@ -31,7 +32,7 @@ describe('', () => { ], }, }); - const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, SearchBar); + const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, ShortUrlsFilteringBar); const createWrapper = (defaultOrdering: ShortUrlsOrder = {}) => shallow( ({ mercureInfo: { loading: true } })} @@ -56,7 +57,7 @@ describe('', () => { expect(wrapper.find(ShortUrlsTable)).toHaveLength(1); expect(wrapper.find(OrderingDropdown)).toHaveLength(1); expect(wrapper.find(Paginator)).toHaveLength(1); - expect(wrapper.find(SearchBar)).toHaveLength(1); + expect(wrapper.find(ShortUrlsFilteringBar)).toHaveLength(1); }); it('passes current query to paginator', () => { diff --git a/test/utils/helpers/ordering.test.ts b/test/utils/helpers/ordering.test.ts index 147d8d1f..20be691b 100644 --- a/test/utils/helpers/ordering.test.ts +++ b/test/utils/helpers/ordering.test.ts @@ -22,4 +22,12 @@ describe('ordering', () => { expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined(); }); }); + + describe('orderToString', () => { + test.todo('casts the order to string'); + }); + + describe('stringToOrder', () => { + test.todo('casts a string to an order objects'); + }); }); From 3274088b545d904e3d429d82fb45141f399a43da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 25 Dec 2021 19:58:40 +0100 Subject: [PATCH 31/49] Added tests for new ordering helper functions and updated changelog --- CHANGELOG.md | 1 + test/short-urls/ShortUrlsList.test.tsx | 1 - test/utils/helpers/date.test.ts | 4 ++-- test/utils/helpers/ordering.test.ts | 18 +++++++++++++++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fbd3ab..7f45282c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. * [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs. +* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params. ### Changed * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index da6bfec3..9f9cb65f 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -12,7 +12,6 @@ import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; import { Settings } from '../../src/settings/reducers/settings'; -import ShortUrlsFilteringBar from '../../src/short-urls/ShortUrlsFilteringBar'; describe('', () => { let wrapper: ShallowWrapper; diff --git a/test/utils/helpers/date.test.ts b/test/utils/helpers/date.test.ts index 349fae49..79dae5bf 100644 --- a/test/utils/helpers/date.test.ts +++ b/test/utils/helpers/date.test.ts @@ -34,7 +34,7 @@ describe('date', () => { }); describe('isBetween', () => { - test.each([ + it.each([ [ now, undefined, undefined, true ], [ now, subDays(now, 1), undefined, true ], [ now, now, undefined, true ], @@ -52,7 +52,7 @@ describe('date', () => { }); describe('isBeforeOrEqual', () => { - test.each([ + it.each([ [ now, now, true ], [ now, addDays(now, 1), true ], [ now, subDays(now, 1), false ], diff --git a/test/utils/helpers/ordering.test.ts b/test/utils/helpers/ordering.test.ts index 20be691b..29111323 100644 --- a/test/utils/helpers/ordering.test.ts +++ b/test/utils/helpers/ordering.test.ts @@ -1,4 +1,4 @@ -import { determineOrderDir } from '../../../src/utils/helpers/ordering'; +import { determineOrderDir, OrderDir, orderToString, stringToOrder } from '../../../src/utils/helpers/ordering'; describe('ordering', () => { describe('determineOrderDir', () => { @@ -24,10 +24,22 @@ describe('ordering', () => { }); describe('orderToString', () => { - test.todo('casts the order to string'); + it.each([ + [{}, undefined ], + [{ field: 'foo' }, undefined ], + [{ field: 'foo', dir: 'ASC' as OrderDir }, 'foo-ASC' ], + [{ field: 'bar', dir: 'DESC' as OrderDir }, 'bar-DESC' ], + ])('casts the order to string', (order, expectedResult) => { + expect(orderToString(order)).toEqual(expectedResult); + }); }); describe('stringToOrder', () => { - test.todo('casts a string to an order objects'); + it.each([ + [ 'foo-ASC', { field: 'foo', dir: 'ASC' }], + [ 'bar-DESC', { field: 'bar', dir: 'DESC' }], + ])('casts a string to an order objects', (order, expectedResult) => { + expect(stringToOrder(order)).toEqual(expectedResult); + }); }); }); From 9348f211f002e4d44c19f88bf87cc25416786649 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 26 Dec 2021 10:42:25 +0100 Subject: [PATCH 32/49] Removed error check which is no longer needed for currently supported Shlink versions --- src/api/services/ShlinkApiClient.ts | 50 ++++++++--------------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index a9906302..4cada88b 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -21,7 +21,7 @@ import { import { stringifyQuery } from '../../utils/helpers/query'; import { orderToString } from '../../utils/helpers/ordering'; -const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; +const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : ''; const rejectNilProps = reject(isNil); const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const { orderBy = {}, ...rest } = params; @@ -30,14 +30,11 @@ const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShor }; export default class ShlinkApiClient { - private apiVersion: number; - public constructor( private readonly axios: AxiosInstance, private readonly baseUrl: string, private readonly apiKey: string, ) { - this.apiVersion = 2; } public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise => @@ -75,7 +72,10 @@ export default class ShlinkApiClient { this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) .then(() => {}); - /* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */ + // eslint-disable-next-line valid-jsdoc + /** + * @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead + */ public readonly updateShortUrlTags = async ( shortCode: string, domain: OptionalString, @@ -121,35 +121,13 @@ export default class ShlinkApiClient { ): Promise => this.performRequest('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data); - private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => { - try { - return await this.axios({ - method, - url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`, - headers: { 'X-Api-Key': this.apiKey }, - params: rejectNilProps(query), - data: body, - paramsSerializer: stringifyQuery, - }); - } catch (e: any) { - const { response } = e; - - // Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error - // when performed from the browser (due to the preflight request not returning a 2xx status. - // See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here. - // The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as - // if a request has been performed to a not supported API version. - const apiVersionIsNotSupported = !response; - - // When the request is not invalid or we have already tried both API versions, throw the error and let the - // caller handle it - if (!apiVersionIsNotSupported || this.apiVersion === 2) { - throw e; - } - - this.apiVersion = this.apiVersion - 1; - - return await this.performRequest(url, method, query, body); - } - }; + private readonly performRequest = async (url: string, method: Method = 'GET', query = {}, body = {}): Promise> => + this.axios({ + method, + url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`, + headers: { 'X-Api-Key': this.apiKey }, + params: rejectNilProps(query), + data: body, + paramsSerializer: stringifyQuery, + }); } From ace29ca4a42354ed4fe30787daf09f2c1882c238 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 26 Dec 2021 13:21:09 +0100 Subject: [PATCH 33/49] Created helper function to replace the authority on a URL --- src/utils/helpers/uri.ts | 7 +++++++ test/utils/helpers/uri.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/utils/helpers/uri.ts create mode 100644 test/utils/helpers/uri.test.ts diff --git a/src/utils/helpers/uri.ts b/src/utils/helpers/uri.ts new file mode 100644 index 00000000..a9a41d84 --- /dev/null +++ b/src/utils/helpers/uri.ts @@ -0,0 +1,7 @@ +export const replaceAuthorityFromUri = (uri: string, newAuthority: string): string => { + const [ schema, rest ] = uri.split('://'); + const [ , ...pathParts ] = rest.split('/'); + const normalizedPath = pathParts.length ? `/${pathParts.join('/')}` : ''; + + return `${schema}://${newAuthority}${normalizedPath}`; +}; diff --git a/test/utils/helpers/uri.test.ts b/test/utils/helpers/uri.test.ts new file mode 100644 index 00000000..6ae4d072 --- /dev/null +++ b/test/utils/helpers/uri.test.ts @@ -0,0 +1,13 @@ +import { replaceAuthorityFromUri } from '../../../src/utils/helpers/uri'; + +describe('uri-helper', () => { + describe('replaceAuthorityFromUri', () => { + it.each([ + [ 'http://something.com/foo/bar', 'www.new.to', 'http://www.new.to/foo/bar' ], + [ 'https://www.authori.ty:8000/', 'doma.in', 'https://doma.in/' ], + [ 'http://localhost:8080/this/is-a-long/path', 'somewhere:8888', 'http://somewhere:8888/this/is-a-long/path' ], + ])('replaces authority as expected', (uri, newAuthority, expectedResult) => { + expect(replaceAuthorityFromUri(uri, newAuthority)).toEqual(expectedResult); + }); + }); +}); From c05c74f0090382d6435a9f40e78c869245346bb4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 26 Dec 2021 13:38:17 +0100 Subject: [PATCH 34/49] Extended domainsList reducer, adding functionality to verify domains statuses --- src/domains/data/index.ts | 7 ++ src/domains/reducers/domainsList.ts | 62 ++++++++++++-- test/domains/reducers/domainsList.test.ts | 100 +++++++++++++++++++--- 3 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 src/domains/data/index.ts diff --git a/src/domains/data/index.ts b/src/domains/data/index.ts new file mode 100644 index 00000000..e427d87d --- /dev/null +++ b/src/domains/data/index.ts @@ -0,0 +1,7 @@ +import { ShlinkDomain } from '../../api/types'; + +export type DomainStatus = 'validating' | 'valid' | 'invalid'; + +export interface Domain extends ShlinkDomain { + status: DomainStatus; +} diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 6ade3fb3..1769b189 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,10 +1,13 @@ import { Action, Dispatch } from 'redux'; -import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { ProblemDetailsError, 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 { Domain, DomainStatus } from '../data'; +import { hasServerData } from '../../servers/data'; +import { replaceAuthorityFromUri } from '../../utils/helpers/uri'; import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; /* eslint-disable padding-line-between-statements */ @@ -12,11 +15,12 @@ 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'; +export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; /* eslint-enable padding-line-between-statements */ export interface DomainsList { - domains: ShlinkDomain[]; - filteredDomains: ShlinkDomain[]; + domains: Domain[]; + filteredDomains: Domain[]; defaultRedirects?: ShlinkDomainRedirects; loading: boolean; error: boolean; @@ -24,7 +28,7 @@ export interface DomainsList { } export interface ListDomainsAction extends Action { - domains: ShlinkDomain[]; + domains: Domain[]; defaultRedirects?: ShlinkDomainRedirects; } @@ -32,6 +36,11 @@ interface FilterDomainsAction extends Action { searchTerm: string; } +interface ValidateDomain extends Action { + domain: string; + status: DomainStatus; +} + const initialState: DomainsList = { domains: [], filteredDomains: [], @@ -42,10 +51,14 @@ const initialState: DomainsList = { export type DomainsCombinedAction = ListDomainsAction & ApiErrorAction & FilterDomainsAction -& EditDomainRedirectsAction; +& EditDomainRedirectsAction +& ValidateDomain; export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => - (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; + (d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects }; + +export const replaceStatusOnDomain = (domain: string, status: DomainStatus) => + (d: Domain): Domain => d.domain !== domain ? d : { ...d, status }; export default buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), @@ -61,6 +74,11 @@ export default buildReducer({ domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), }), + [VALIDATE_DOMAIN]: (state, { domain, status }) => ({ + ...state, + domains: state.domains.map(replaceStatusOnDomain(domain, status)), + filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)), + }), }, initialState); export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( @@ -71,7 +89,10 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () const { listDomains } = buildShlinkApiClient(getState); try { - const { data: domains, defaultRedirects } = await listDomains(); + const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({ + domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })), + defaultRedirects, + })); dispatch({ type: LIST_DOMAINS, domains, defaultRedirects }); } catch (e: any) { @@ -80,3 +101,30 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () }; export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); + +export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async ( + dispatch: Dispatch, + getState: GetState, +) => { + const { selectedServer } = getState(); + + if (!hasServerData(selectedServer)) { + dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + + return; + } + + try { + const { url, ...rest } = selectedServer; + const { health } = buildShlinkApiClient({ + ...rest, + url: replaceAuthorityFromUri(url, domain), + }); + + const { status } = await health(); + + dispatch({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' }); + } catch (e) { + dispatch({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + } +}; diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 83845197..25842eb7 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -4,19 +4,35 @@ import reducer, { LIST_DOMAINS_ERROR, LIST_DOMAINS_START, FILTER_DOMAINS, + VALIDATE_DOMAIN, DomainsCombinedAction, DomainsList, listDomains as listDomainsAction, filterDomains as filterDomainsAction, replaceRedirectsOnDomain, + checkDomainHealth, + replaceStatusOnDomain, } from '../../../src/domains/reducers/domainsList'; import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects'; -import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types'; +import { ShlinkDomainRedirects } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; +import { Domain } from '../../../src/domains/data'; +import { ShlinkState } from '../../../src/container/types'; +import { SelectedServer, ServerData } from '../../../src/servers/data'; describe('domainsListReducer', () => { - const filteredDomains = [ Mock.of({ domain: 'foo' }), Mock.of({ domain: 'boo' }) ]; - const domains = [ ...filteredDomains, Mock.of({ domain: 'bar' }) ]; + const dispatch = jest.fn(); + const getState = jest.fn(); + const listDomains = jest.fn(); + const health = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ listDomains, health }); + const filteredDomains = [ + Mock.of({ domain: 'foo', status: 'validating' }), + Mock.of({ domain: 'boo', status: 'validating' }), + ]; + const domains = [ ...filteredDomains, Mock.of({ domain: 'bar', status: 'validating' }) ]; + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const action = (type: string, args: Partial = {}) => Mock.of( @@ -66,16 +82,23 @@ describe('domainsListReducer', () => { filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), }); }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'does_not_exist' ], + ])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => { + expect(reducer( + Mock.of({ domains, filteredDomains }), + action(VALIDATE_DOMAIN, { domain, status: 'valid' }), + )).toEqual({ + domains: domains.map(replaceStatusOnDomain(domain, 'valid')), + filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')), + }); + }); }); describe('listDomains', () => { - const dispatch = jest.fn(); - const getState = jest.fn(); - const listDomains = jest.fn(); - const buildShlinkApiClient = () => Mock.of({ listDomains }); - - beforeEach(jest.clearAllMocks); - it('dispatches error when loading domains fails', async () => { listDomains.mockRejectedValue(new Error('error')); @@ -108,4 +131,61 @@ describe('domainsListReducer', () => { expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm }); }); }); + + describe('checkDomainHealth', () => { + const domain = 'example.com'; + + it('dispatches invalid status when selected server does not have all required data', async () => { + getState.mockReturnValue(Mock.of({ + selectedServer: Mock.all(), + })); + + await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + + expect(getState).toHaveBeenCalledTimes(1); + expect(health).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + }); + + it('dispatches invalid status when health endpoint returns an error', async () => { + getState.mockReturnValue(Mock.of({ + selectedServer: Mock.of({ + url: 'https://myerver.com', + apiKey: '123', + }), + })); + health.mockRejectedValue({}); + + await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + + expect(getState).toHaveBeenCalledTimes(1); + expect(health).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' }); + }); + + it.each([ + [ 'pass', 'valid' ], + [ 'fail', 'invalid' ], + ])('dispatches proper status based on status returned from health endpoint', async ( + healthStatus, + expectedStatus, + ) => { + getState.mockReturnValue(Mock.of({ + selectedServer: Mock.of({ + url: 'https://myerver.com', + apiKey: '123', + }), + })); + health.mockResolvedValue({ status: healthStatus }); + + await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState); + + expect(getState).toHaveBeenCalledTimes(1); + expect(health).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: expectedStatus }); + }); + }); }); From a78467065a16ce69891f0eb9b6a675701d7f7210 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 26 Dec 2021 13:53:17 +0100 Subject: [PATCH 35/49] Added logic in ManageDomains and DomainRow components to check if the domains status --- src/domains/DomainRow.tsx | 34 ++++++++++++++++++++----- src/domains/ManageDomains.tsx | 6 +++-- src/domains/services/provideServices.ts | 5 ++-- test/domains/DomainRow.test.tsx | 34 +++++++++++++++---------- test/domains/ManageDomains.test.tsx | 6 ++--- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index 1a2f67ea..0cb7b5f7 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -1,22 +1,26 @@ -import { FC } from 'react'; +import { FC, useEffect } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBan as forbiddenIcon, - faCheck as defaultDomainIcon, + faDotCircle as defaultDomainIcon, + faCheck as checkIcon, + faCircleNotch as loadingStatusIcon, faEdit as editIcon, } from '@fortawesome/free-solid-svg-icons'; -import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types'; +import { ShlinkDomainRedirects } from '../api/types'; import { useToggle } from '../utils/helpers/hooks'; import { OptionalString } from '../utils/utils'; import { SelectedServer } from '../servers/data'; import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features'; import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; +import { Domain, DomainStatus } from './data'; interface DomainRowProps { - domain: ShlinkDomain; + domain: Domain; defaultRedirects?: ShlinkDomainRedirects; editDomainRedirects: (domain: string, redirects: Partial) => Promise; + checkDomainHealth: (domain: string) => void; selectedServer: SelectedServer; } @@ -32,12 +36,27 @@ const DefaultDomain: FC = () => ( Default domain ); +const StatusIcon: FC<{ status: DomainStatus }> = ({ status }) => { + if (status === 'validating') { + return ; + } -export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects, selectedServer }) => { + return status === 'valid' + ? + : ; +}; + +export const DomainRow: FC = ( + { domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer }, +) => { const [ isOpen, toggle ] = useToggle(); - const { domain: authority, isDefault, redirects } = domain; + const { domain: authority, isDefault, redirects, status } = domain; const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer); + useEffect(() => { + checkDomainHealth(domain.domain); + }, []); + return ( {isDefault ? : ''} @@ -51,6 +70,9 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def {redirects?.invalidShortUrlRedirect ?? } + + +

} onSubmit={handleSubmit}> + Add new server

} onSubmit={setServerData}> {!hasServers && } {hasServers && } @@ -49,6 +64,14 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT {serversImported && } {errorImporting && } + + ); }; diff --git a/src/servers/helpers/DuplicatedServerModal.tsx b/src/servers/helpers/DuplicatedServerModal.tsx new file mode 100644 index 00000000..497174c2 --- /dev/null +++ b/src/servers/helpers/DuplicatedServerModal.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { ServerData } from '../data'; + +interface DuplicatedServerModalProps { + serverData?: ServerData; + isOpen: boolean; + toggle: () => void; + onDiscard: () => void; + onSave: () => void; +} + +export const DuplicatedServerModal: FC = ( + { isOpen, toggle, serverData, onDiscard, onSave }, +) => ( + + Duplicated server + +

There is already a server with:

+
    +
  • URL: {serverData?.url}
  • +
  • API key: {serverData?.apiKey}
  • +
+ Do you want to save this server anyway? +
+ + + + +
+); diff --git a/test/servers/CreateServer.test.tsx b/test/servers/CreateServer.test.tsx index 7cb2e388..795db3e4 100644 --- a/test/servers/CreateServer.test.tsx +++ b/test/servers/CreateServer.test.tsx @@ -4,18 +4,21 @@ import { History } from 'history'; import createServerConstruct from '../../src/servers/CreateServer'; import { ServerForm } from '../../src/servers/helpers/ServerForm'; import { ServerWithId } from '../../src/servers/data'; +import { DuplicatedServerModal } from '../../src/servers/helpers/DuplicatedServerModal'; describe('', () => { let wrapper: ShallowWrapper; const ImportServersBtn = () => null; const createServerMock = jest.fn(); const push = jest.fn(); - const historyMock = Mock.of({ push }); + const goBack = jest.fn(); + const historyMock = Mock.of({ push, goBack }); const servers = { foo: Mock.all() }; const createWrapper = (serversImported = false, importFailed = false) => { const useStateFlagTimeout = jest.fn() .mockReturnValueOnce([ serversImported, () => '' ]) - .mockReturnValueOnce([ importFailed, () => '' ]); + .mockReturnValueOnce([ importFailed, () => '' ]) + .mockReturnValue([]); const CreateServer = createServerConstruct(ImportServersBtn, useStateFlagTimeout); wrapper = shallow(); @@ -23,10 +26,8 @@ describe('', () => { return wrapper; }; - afterEach(() => { - jest.resetAllMocks(); - wrapper?.unmount(); - }); + beforeEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); it('renders components', () => { const wrapper = createWrapper(); @@ -51,13 +52,30 @@ describe('', () => { expect(result.prop('type')).toEqual('error'); }); - it('creates server and redirects to it when form is submitted', () => { + it('creates server data form is submitted', () => { const wrapper = createWrapper(); const form = wrapper.find(ServerForm); + expect(wrapper.find(DuplicatedServerModal).prop('serverData')).not.toBeDefined(); form.simulate('submit', {}); + expect(wrapper.find(DuplicatedServerModal).prop('serverData')).toEqual({}); + }); + + it('saves server and redirects on modal save', () => { + const wrapper = createWrapper(); + + wrapper.find(ServerForm).simulate('submit', {}); + wrapper.find(DuplicatedServerModal).simulate('save'); expect(createServerMock).toHaveBeenCalledTimes(1); expect(push).toHaveBeenCalledTimes(1); }); + + it('goes back on modal discard', () => { + const wrapper = createWrapper(); + + wrapper.find(DuplicatedServerModal).simulate('discard'); + + expect(goBack).toHaveBeenCalledTimes(1); + }); }); From 053b38bee3dafb506748a4cd0f908574d5b467af Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 30 Dec 2021 21:40:08 +0100 Subject: [PATCH 44/49] Created DuplicatedServerModal test --- .../helpers/DuplicatedServerModal.test.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/servers/helpers/DuplicatedServerModal.test.tsx diff --git a/test/servers/helpers/DuplicatedServerModal.test.tsx b/test/servers/helpers/DuplicatedServerModal.test.tsx new file mode 100644 index 00000000..4eaa5737 --- /dev/null +++ b/test/servers/helpers/DuplicatedServerModal.test.tsx @@ -0,0 +1,56 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { Button } from 'reactstrap'; +import { DuplicatedServerModal } from '../../../src/servers/helpers/DuplicatedServerModal'; +import { ServerData } from '../../../src/servers/data'; + +describe('', () => { + const onDiscard = jest.fn(); + const onSave = jest.fn(); + let wrapper: ShallowWrapper; + const createWrapper = (serverData?: ServerData) => { + wrapper = shallow( + , + ); + + return wrapper; + }; + + beforeEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it.each([ + [ undefined ], + [ Mock.of({ url: 'url', apiKey: 'apiKey' }) ], + ])('displays provided server data', (serverData) => { + const wrapper = createWrapper(serverData); + const li = wrapper.find('li'); + + expect(li.first().find('b').html()).toEqual(`${serverData?.url ?? ''}`); + expect(li.last().find('b').html()).toEqual(`${serverData?.apiKey ?? ''}`); + }); + + it('invokes onDiscard when appropriate button is clicked', () => { + const wrapper = createWrapper(); + const btn = wrapper.find(Button).first(); + + btn.simulate('click'); + + expect(onDiscard).toHaveBeenCalled(); + }); + + it('invokes onSave when appropriate button is clicked', () => { + const wrapper = createWrapper(); + const btn = wrapper.find(Button).last(); + + btn.simulate('click'); + + expect(onSave).toHaveBeenCalled(); + }); +}); From 3cb066f5f581fd97f65cc2db9acd332344a5da2f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 31 Dec 2021 17:56:37 +0100 Subject: [PATCH 45/49] Reduced unnecesary lines in test --- test/servers/helpers/DuplicatedServerModal.test.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/servers/helpers/DuplicatedServerModal.test.tsx b/test/servers/helpers/DuplicatedServerModal.test.tsx index 4eaa5737..8e3124a3 100644 --- a/test/servers/helpers/DuplicatedServerModal.test.tsx +++ b/test/servers/helpers/DuplicatedServerModal.test.tsx @@ -10,13 +10,7 @@ describe('', () => { let wrapper: ShallowWrapper; const createWrapper = (serverData?: ServerData) => { wrapper = shallow( - , + , ); return wrapper; From 98398a048bdae49481f3251d24aa85c38889ede2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jan 2022 12:20:09 +0100 Subject: [PATCH 46/49] Added logic to detect duplicated servers when importing a servers list --- src/servers/CreateServer.tsx | 7 ++- src/servers/helpers/DuplicatedServerModal.tsx | 31 ------------ .../helpers/DuplicatedServersModal.tsx | 38 ++++++++++++++ src/servers/helpers/ImportServersBtn.tsx | 50 ++++++++++++++++--- src/servers/services/ServersImporter.ts | 2 +- src/servers/services/provideServices.ts | 4 +- test/servers/CreateServer.test.tsx | 12 ++--- ...st.tsx => DuplicatedServersModal.test.tsx} | 24 +++++---- .../servers/helpers/ImportServersBtn.test.tsx | 31 ++++++++---- test/servers/services/ServersImporter.test.ts | 2 +- 10 files changed, 128 insertions(+), 73 deletions(-) delete mode 100644 src/servers/helpers/DuplicatedServerModal.tsx create mode 100644 src/servers/helpers/DuplicatedServersModal.tsx rename test/servers/helpers/{DuplicatedServerModal.test.tsx => DuplicatedServersModal.test.tsx} (53%) diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index 6eb3c2da..0412016a 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -8,7 +8,7 @@ import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks'; import { ServerForm } from './helpers/ServerForm'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ServerData, ServersMap, ServerWithId } from './data'; -import { DuplicatedServerModal } from './helpers/DuplicatedServerModal'; +import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; const SHOW_IMPORT_MSG_TIME = 4000; @@ -65,10 +65,9 @@ const CreateServer = (ImportServersBtn: FC, useStateFlagT {serversImported && } {errorImporting && } - diff --git a/src/servers/helpers/DuplicatedServerModal.tsx b/src/servers/helpers/DuplicatedServerModal.tsx deleted file mode 100644 index 497174c2..00000000 --- a/src/servers/helpers/DuplicatedServerModal.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { FC } from 'react'; -import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import { ServerData } from '../data'; - -interface DuplicatedServerModalProps { - serverData?: ServerData; - isOpen: boolean; - toggle: () => void; - onDiscard: () => void; - onSave: () => void; -} - -export const DuplicatedServerModal: FC = ( - { isOpen, toggle, serverData, onDiscard, onSave }, -) => ( - - Duplicated server - -

There is already a server with:

-
    -
  • URL: {serverData?.url}
  • -
  • API key: {serverData?.apiKey}
  • -
- Do you want to save this server anyway? -
- - - - -
-); diff --git a/src/servers/helpers/DuplicatedServersModal.tsx b/src/servers/helpers/DuplicatedServersModal.tsx new file mode 100644 index 00000000..238d2f10 --- /dev/null +++ b/src/servers/helpers/DuplicatedServersModal.tsx @@ -0,0 +1,38 @@ +import { FC, Fragment } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { ServerData } from '../data'; + +interface DuplicatedServersModalProps { + duplicatedServers: ServerData[]; + isOpen: boolean; + onDiscard: () => void; + onSave: () => void; +} + +export const DuplicatedServersModal: FC = ( + { isOpen, duplicatedServers, onDiscard, onSave }, +) => { + const hasMultipleServers = duplicatedServers.length > 1; + + return ( + + Duplicated server{hasMultipleServers && 's'} + +

{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}

+
    + {duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? ( + +
  • URL: {url}
  • +
  • API key: {apiKey}
  • +
    + ) :
  • {url} - {apiKey}
  • )} +
+ {hasMultipleServers ? 'Do you want to ignore duplicated servers?' : 'Do you want to save this server anyway?'} +
+ + + + +
+ ); +}; diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 3f27cfc2..2a91e71c 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,10 +1,12 @@ -import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react'; +import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; -import { pipe } from 'ramda'; +import { complement, pipe } from 'ramda'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import ServersImporter from '../services/ServersImporter'; -import { ServerData } from '../data'; +import { useToggle } from '../../utils/helpers/hooks'; +import { ServersImporter } from '../services/ServersImporter'; +import { ServerData, ServersMap } from '../data'; +import { DuplicatedServersModal } from './DuplicatedServersModal'; import './ImportServersBtn.scss'; type Ref = RefObject | MutableRefObject; @@ -18,11 +20,16 @@ export interface ImportServersBtnProps { interface ImportServersBtnConnectProps extends ImportServersBtnProps { createServers: (servers: ServerData[]) => void; + servers: ServersMap; fileRef: Ref; } +const serversFiltering = (servers: ServerData[]) => + ({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey); + const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC => ({ createServers, + servers, fileRef, children, onImport = () => {}, @@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC { const ref = fileRef ?? useRef(); - const onChange = async ({ target }: ChangeEvent) => + const [ serversToCreate, setServersToCreate ] = useState(); + const [ duplicatedServers, setDuplicatedServers ] = useState([]); + const [ isModalOpen,, showModal, hideModal ] = useToggle(); + const create = pipe(createServers, onImport); + const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal); + const createNonDuplicatedServers = pipe( + () => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))), + hideModal, + ); + const onFile = async ({ target }: ChangeEvent) => importServersFromFile(target.files?.[0]) - .then(pipe(createServers, onImport)) + .then(setServersToCreate) .then(() => { // Reset input after processing file (target as { value: string | null }).value = null; }) .catch(onImportError); + useEffect(() => { + if (!serversToCreate) { + return; + } + + const existingServers = Object.values(servers); + const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers)); + const hasDuplicatedServers = !!duplicatedServers.length; + + !hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers); + hasDuplicatedServers && showModal(); + }, [ serversToCreate ]); + return ( <> diff --git a/test/servers/helpers/DuplicatedServersModal.test.tsx b/test/servers/helpers/DuplicatedServersModal.test.tsx index 8810aac3..6e2b6731 100644 --- a/test/servers/helpers/DuplicatedServersModal.test.tsx +++ b/test/servers/helpers/DuplicatedServersModal.test.tsx @@ -1,6 +1,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; -import { Button } from 'reactstrap'; +import { Button, ModalHeader } from 'reactstrap'; import { DuplicatedServersModal } from '../../../src/servers/helpers/DuplicatedServersModal'; import { ServerData } from '../../../src/servers/data'; @@ -19,6 +19,51 @@ describe('', () => { beforeEach(jest.clearAllMocks); afterEach(() => wrapper?.unmount()); + it.each([ + [[], 0 ], + [[ Mock.all() ], 2 ], + [[ Mock.all(), Mock.all() ], 2 ], + [[ Mock.all(), Mock.all(), Mock.all() ], 3 ], + [[ Mock.all(), Mock.all(), Mock.all(), Mock.all() ], 4 ], + ])('renders expected amount of items', (duplicatedServers, expectedItems) => { + const wrapper = createWrapper(duplicatedServers); + const li = wrapper.find('li'); + + expect(li).toHaveLength(expectedItems); + }); + + it.each([ + [ + [ Mock.all() ], + { + header: 'Duplicated server', + firstParagraph: 'There is already a server with:', + lastParagraph: 'Do you want to save this server anyway?', + discardBtn: 'Discard', + }, + ], + [ + [ Mock.all(), Mock.all() ], + { + header: 'Duplicated servers', + firstParagraph: 'The next servers already exist:', + lastParagraph: 'Do you want to ignore duplicated servers?', + discardBtn: 'Ignore duplicated', + }, + ], + ])('renders expected texts based on amount of servers', (duplicatedServers, assertions) => { + const wrapper = createWrapper(duplicatedServers); + const header = wrapper.find(ModalHeader); + const p = wrapper.find('p'); + const span = wrapper.find('span'); + const discardBtn = wrapper.find(Button).first(); + + expect(header.html()).toContain(assertions.header); + expect(p.html()).toContain(assertions.firstParagraph); + expect(span.html()).toContain(assertions.lastParagraph); + expect(discardBtn.html()).toContain(assertions.discardBtn); + }); + it.each([ [[]], [[ Mock.of({ url: 'url', apiKey: 'apiKey' }) ]], @@ -28,9 +73,16 @@ describe('', () => { if (duplicatedServers.length === 0) { expect(li).toHaveLength(0); - } else { + } else if (duplicatedServers.length === 1) { expect(li.first().find('b').html()).toEqual(`${duplicatedServers[0].url}`); expect(li.last().find('b').html()).toEqual(`${duplicatedServers[0].apiKey}`); + } else { + expect.assertions(duplicatedServers.length); + li.forEach((item, index) => { + const server = duplicatedServers[index]; + + expect(item.html()).toContain(`${server.url} - ${server.apiKey}`); + }); } }); From ba667a0768015178e5318f76b2bf1e2091a685d8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jan 2022 12:38:00 +0100 Subject: [PATCH 48/49] Updated changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534d1d80..85189cd4 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.5.0] - 2022-01-01 ### Added * [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter". @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The warning includes a link to the documentation, explaining what are the steps to get it fixed. +* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, dispaying a warning when creating or importing servers that already exist. * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. * [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs. From 4f03ab18e502704d4718260a8bc30b64a9e8b2ef Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jan 2022 12:47:54 +0100 Subject: [PATCH 49/49] Fixed typo in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85189cd4..8684440f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The warning includes a link to the documentation, explaining what are the steps to get it fixed. -* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, dispaying a warning when creating or importing servers that already exist. +* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist. * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. * [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.