From a28a4846bca597090a02aa350d8d13dffcddab18 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Aug 2021 17:30:07 +0200 Subject: [PATCH 01/17] Created base structure to manage domains --- src/api/types/index.ts | 7 ++ src/common/AsideMenu.tsx | 27 +++++--- src/common/MenuLayout.tsx | 5 +- src/common/services/provideServices.ts | 1 + src/domains/ManageDomains.tsx | 92 +++++++++++++++++++++++++ src/domains/services/provideServices.ts | 4 ++ src/mercure/helpers/Topics.ts | 6 +- src/servers/Overview.tsx | 2 +- src/short-urls/ShortUrlsList.tsx | 2 +- src/tags/TagsList.tsx | 7 +- src/utils/helpers/features.ts | 2 + src/visits/OrphanVisits.tsx | 2 +- src/visits/TagVisits.tsx | 2 +- 13 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 src/domains/ManageDomains.tsx diff --git a/src/api/types/index.ts b/src/api/types/index.ts index acd0d4f7..9d75eaf5 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -65,9 +65,16 @@ export interface ShlinkShortUrlData extends ShortUrlMeta { tags?: string[]; } +interface ShlinkDomainRedirects { + baseUrlRedirect: string, + regular404Redirect: string, + invalidShortUrlRedirect: string +} + export interface ShlinkDomain { domain: string; isDefault: boolean; + redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8 } export interface ShlinkDomainsResponse { diff --git a/src/common/AsideMenu.tsx b/src/common/AsideMenu.tsx index 3d9fd4b8..4f7ec14a 100644 --- a/src/common/AsideMenu.tsx +++ b/src/common/AsideMenu.tsx @@ -4,6 +4,7 @@ import { faTags as tagsIcon, faPen as editIcon, faHome as overviewIcon, + faGlobe as domainsIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FC } from 'react'; @@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom'; import classNames from 'classnames'; import { Location } from 'history'; import { DeleteServerButtonProps } from '../servers/DeleteServerButton'; -import { ServerWithId } from '../servers/data'; +import { isServerWithId, SelectedServer } from '../servers/data'; +import { supportsDomainRedirects } from '../utils/helpers/features'; import './AsideMenu.scss'; export interface AsideMenuProps { - selectedServer: ServerWithId; + selectedServer: SelectedServer; className?: string; showOnMobile?: boolean; } @@ -38,7 +40,8 @@ const AsideMenuItem: FC = ({ children, to, className, ...res const AsideMenu = (DeleteServerButton: FC) => ( { selectedServer, showOnMobile = false }: AsideMenuProps, ) => { - const serverId = selectedServer ? selectedServer.id : ''; + const serverId = isServerWithId(selectedServer) ? selectedServer.id : ''; + const addManageDomainsLink = supportsDomainRedirects(selectedServer); const asideClass = classNames('aside-menu', { 'aside-menu--hidden': !showOnMobile, }); @@ -64,15 +67,23 @@ const AsideMenu = (DeleteServerButton: FC) => ( Manage tags + {addManageDomainsLink && ( + + + Manage domains + + )} Edit this server - + {isServerWithId(selectedServer) && ( + + )} ); diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 669b2197..871a0f97 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { useSwipeable, useToggle } from '../utils/helpers/hooks'; -import { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features'; +import { supportsDomainRedirects, supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features'; import { isReachableServer } from '../servers/data'; import NotFound from './NotFound'; import { AsideMenuProps } from './AsideMenu'; @@ -22,6 +22,7 @@ const MenuLayout = ( ServerError: FC, Overview: FC, EditShortUrl: FC, + ManageDomains: FC, ) => withSelectedServer(({ location, selectedServer }) => { const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); @@ -33,6 +34,7 @@ const MenuLayout = ( const addTagsVisitsRoute = supportsTagVisits(selectedServer); const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer); + const addManageDomainsRoute = supportsDomainRedirects(selectedServer); const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); const swipeableProps = useSwipeable(showSidebar, hideSidebar); @@ -55,6 +57,7 @@ const MenuLayout = ( {addTagsVisitsRoute && } {addOrphanVisitsRoute && } + {addManageDomainsRoute && } List short URLs} /> diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index a7a71139..2abbe688 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -43,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: 'ServerError', 'Overview', 'EditShortUrl', + 'ManageDomains', ); bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', withRouter); diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx new file mode 100644 index 00000000..5d392cc5 --- /dev/null +++ b/src/domains/ManageDomains.tsx @@ -0,0 +1,92 @@ +import { FC, useEffect } from 'react'; +import { faCheck as defaultDomainIcon, faEdit as editIcon, faBan as forbiddenIcon } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, UncontrolledTooltip } from 'reactstrap'; +import Message from '../utils/Message'; +import { Result } from '../utils/Result'; +import { ShlinkApiError } from '../api/ShlinkApiError'; +import { SimpleCard } from '../utils/SimpleCard'; +import { DomainsList } from './reducers/domainsList'; +import SearchField from '../utils/SearchField'; + +interface ManageDomainsProps { + listDomains: Function; + domainsList: DomainsList; +} + +const Na: FC = () => N/A; +const DefaultDomain: FC = () => ( + <> + + Default domain + +); + +export const ManageDomains: FC = ({ listDomains, domainsList }) => { + const { domains, loading, error } = domainsList; + + useEffect(() => { + listDomains(); + }, []); + + const renderContent = () => { + if (loading) { + return ; + } + + if (error) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + {domains.map((domain) => ( + + + + + + + + + ))} + +
+ DomainBase path redirectRegular 404 redirectInvalid short URL redirect +
{domain.isDefault ? : ''}{domain.domain}{domain.redirects?.baseUrlRedirect ?? }{domain.redirects?.regular404Redirect ?? }{domain.redirects?.invalidShortUrlRedirect ?? } + + + + {domain.isDefault && ( + + Redirects for default domain cannot be edited here. + + )} +
+
+ ); + }; + + return ( + <> + {}} /> + {renderContent()} + + ); +}; diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index bd56d8a2..bf90cc7a 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -2,12 +2,16 @@ import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; import { listDomains } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; +import { ManageDomains } from '../ManageDomains'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('DomainSelector', () => DomainSelector); bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); + bottle.serviceFactory('ManageDomains', () => ManageDomains); + bottle.decorator('ManageDomains', connect([ 'domainsList' ], [ 'listDomains' ])); + // Actions bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); }; diff --git a/src/mercure/helpers/Topics.ts b/src/mercure/helpers/Topics.ts index 42e08d4f..663cc371 100644 --- a/src/mercure/helpers/Topics.ts +++ b/src/mercure/helpers/Topics.ts @@ -1,7 +1,7 @@ export class Topics { - public static visits = () => 'https://shlink.io/new-visit'; + public static readonly visits = 'https://shlink.io/new-visit'; - public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`; + public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit'; - public static orphanVisits = () => 'https://shlink.io/new-orphan-visit'; + public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`; } diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index ea156041..84614b2e 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -120,4 +120,4 @@ export const Overview = ( ); -}, () => [ Topics.visits(), Topics.orphanVisits() ]); +}, () => [ Topics.visits, Topics.orphanVisits ]); diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 4736bc8c..1cac40b5 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default ShortUrlsList; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index fde6cc5e..a29345b0 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -1,5 +1,6 @@ import { FC, useEffect, useState } from 'react'; import { splitEvery } from 'ramda'; +import { Row } from 'reactstrap'; import Message from '../utils/Message'; import SearchField from '../utils/SearchField'; import { SelectedServer } from '../servers/data'; @@ -51,7 +52,7 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags); return ( -
+ {tagsGroups.map((group, index) => (
{group.map((tag) => ( @@ -66,7 +67,7 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( ))}
))} -
+ ); }; @@ -76,6 +77,6 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( {renderContent()} ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default TagsList; diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 609c9e95..68651fcc 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -29,3 +29,5 @@ export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' }); export const supportsCrawlableVisits = supportsBotVisits; export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' }); + +export const supportsDomainRedirects = supportsQrErrorCorrection; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index 8184e687..e87a93fe 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -41,4 +41,4 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure ); -}, () => [ Topics.orphanVisits() ]); +}, () => [ Topics.orphanVisits ]); diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index 4a80519f..d7619a0a 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -43,6 +43,6 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default TagVisits; From bf29158a8a3ec1b1b16aa15b4a2530ad7e9ea3c5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 20 Aug 2021 17:31:42 +0200 Subject: [PATCH 02/17] Added missing alignment --- src/domains/ManageDomains.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 5d392cc5..82471d5f 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -6,8 +6,8 @@ import Message from '../utils/Message'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; -import { DomainsList } from './reducers/domainsList'; import SearchField from '../utils/SearchField'; +import { DomainsList } from './reducers/domainsList'; interface ManageDomainsProps { listDomains: Function; @@ -63,7 +63,7 @@ export const ManageDomains: FC = ({ listDomains, domainsList {domain.redirects?.baseUrlRedirect ?? } {domain.redirects?.regular404Redirect ?? } {domain.redirects?.invalidShortUrlRedirect ?? } - + + + {domain.isDefault && ( + + Redirects for default domain cannot be edited here. +
+ Use config options or env vars. +
+ )} + + + + ); +}; diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx index 82471d5f..9d94002b 100644 --- a/src/domains/ManageDomains.tsx +++ b/src/domains/ManageDomains.tsx @@ -1,43 +1,41 @@ import { FC, useEffect } from 'react'; -import { faCheck as defaultDomainIcon, faEdit as editIcon, faBan as forbiddenIcon } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, UncontrolledTooltip } from 'reactstrap'; import Message from '../utils/Message'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { SimpleCard } from '../utils/SimpleCard'; import SearchField from '../utils/SearchField'; +import { ShlinkDomainRedirects } from '../api/types'; import { DomainsList } from './reducers/domainsList'; +import { DomainRow } from './DomainRow'; interface ManageDomainsProps { listDomains: Function; + filterDomains: (searchTerm: string) => void; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; domainsList: DomainsList; } -const Na: FC = () => N/A; -const DefaultDomain: FC = () => ( - <> - - Default domain - -); +const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; -export const ManageDomains: FC = ({ listDomains, domainsList }) => { - const { domains, loading, error } = domainsList; +export const ManageDomains: FC = ( + { listDomains, domainsList, filterDomains, editDomainRedirects }, +) => { + const { filteredDomains: domains, loading, error, errorData } = domainsList; + const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects; useEffect(() => { listDomains(); }, []); - const renderContent = () => { - if (loading) { - return ; - } + if (loading) { + return ; + } + const renderContent = () => { if (error) { return ( - + ); } @@ -46,36 +44,17 @@ export const ManageDomains: FC = ({ listDomains, domainsList - - - - - - + {headers.map((column, index) => )} + {domains.length < 1 && } {domains.map((domain) => ( - - - - - - - - + ))}
- DomainBase path redirectRegular 404 redirectInvalid short URL redirect -
{column}
No results found
{domain.isDefault ? : ''}{domain.domain}{domain.redirects?.baseUrlRedirect ?? }{domain.redirects?.regular404Redirect ?? }{domain.redirects?.invalidShortUrlRedirect ?? } - - - - {domain.isDefault && ( - - Redirects for default domain cannot be edited here. - - )} -
@@ -85,7 +64,7 @@ export const ManageDomains: FC = ({ listDomains, domainsList return ( <> - {}} /> + {renderContent()} ); diff --git a/src/domains/helpers/EditDomainRedirectsModal.tsx b/src/domains/helpers/EditDomainRedirectsModal.tsx new file mode 100644 index 00000000..a74b9c4a --- /dev/null +++ b/src/domains/helpers/EditDomainRedirectsModal.tsx @@ -0,0 +1,85 @@ +import { FC, useState } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader, UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { FormGroupContainer } from '../../utils/FormGroupContainer'; +import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils'; + +interface EditDomainRedirectsModalProps { + domain: ShlinkDomain; + isOpen: boolean; + toggle: () => void; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; +} + +const InfoTooltip: FC<{ id: string }> = ({ id, children }) => ( + <> + + {children} + +); + +const FormGroup: FC<{ value: string; onChange: (newValue: string) => void; isLast?: boolean }> = ( + { value, onChange, isLast, children }, +) => ( + + {children} + +); + +export const EditDomainRedirectsModal: FC = ( + { isOpen, toggle, domain, editDomainRedirects }, +) => { + const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(''); + const [ regular404Redirect, setRegular404Redirect ] = useState(''); + const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(''); + const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, { + baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect), + regular404Redirect: nonEmptyValueOrNull(regular404Redirect), + invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect), + }).then(toggle)); + + return ( + +
+ + Edit redirects for {domain.domain} + + + + + Visitors accessing the base url, as in https://{domain.domain}/, will be redirected to this URL. + + Base URL + + + + Visitors accessing a url not matching a short URL pattern, as in https://{domain.domain}/???/[...], + will be redirected to this URL. + + Regular 404 + + + + Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be + redirected to this URL. + + Invalid short URL + + + + + + +
+
+ ); +}; diff --git a/src/domains/reducers/domainRedirects.ts b/src/domains/reducers/domainRedirects.ts new file mode 100644 index 00000000..5b350a13 --- /dev/null +++ b/src/domains/reducers/domainRedirects.ts @@ -0,0 +1,33 @@ +import { Action, Dispatch } from 'redux'; +import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import { ShlinkDomainRedirects } from '../../api/types'; +import { GetState } from '../../container/types'; +import { ApiErrorAction } from '../../api/types/actions'; +import { parseApiError } from '../../api/utils'; + +/* eslint-disable padding-line-between-statements */ +export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START'; +export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR'; +export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS'; +/* eslint-enable padding-line-between-statements */ + +export interface EditDomainRedirectsAction extends Action { + domain: string; + redirects: ShlinkDomainRedirects; +} + +export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + domain: string, + domainRedirects: Partial, +) => async (dispatch: Dispatch, getState: GetState) => { + dispatch({ type: EDIT_DOMAIN_REDIRECTS_START }); + const { editDomainRedirects } = buildShlinkApiClient(getState); + + try { + const redirects = await editDomainRedirects({ domain, ...domainRedirects }); + + dispatch({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); + } catch (e) { + dispatch({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) }); + } +}; diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 751e2f4f..6a5c80b0 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -1,35 +1,63 @@ import { Action, Dispatch } from 'redux'; -import { ShlinkDomain } from '../../api/types'; +import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; +import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; +import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects'; /* eslint-disable padding-line-between-statements */ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START'; export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR'; export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; +export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; /* eslint-enable padding-line-between-statements */ export interface DomainsList { domains: ShlinkDomain[]; + filteredDomains: ShlinkDomain[]; loading: boolean; error: boolean; + errorData?: ProblemDetailsError; } export interface ListDomainsAction extends Action { domains: ShlinkDomain[]; } +interface FilterDomainsAction extends Action { + searchTerm: string; +} + const initialState: DomainsList = { domains: [], + filteredDomains: [], loading: false, error: false, }; -export default buildReducer({ +type DomainsCombinedAction = ListDomainsAction +& ApiErrorAction +& FilterDomainsAction +& EditDomainRedirectsAction; + +const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => + (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; + +export default buildReducer({ [LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }), - [LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }), - [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }), + [LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }), + [LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }), + [FILTER_DOMAINS]: (state, { searchTerm }) => ({ + ...state, + filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)), + }), + [EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({ + ...state, + domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)), + filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), + }), }, initialState); export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( @@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () dispatch({ type: LIST_DOMAINS, domains }); } catch (e) { - dispatch({ type: LIST_DOMAINS_ERROR }); + dispatch({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) }); } }; + +export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm }); diff --git a/src/domains/services/provideServices.ts b/src/domains/services/provideServices.ts index bf90cc7a..e6f01b1b 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -1,8 +1,9 @@ import Bottle from 'bottlejs'; import { ConnectDecorator } from '../../container/types'; -import { listDomains } from '../reducers/domainsList'; +import { filterDomains, listDomains } from '../reducers/domainsList'; import { DomainSelector } from '../DomainSelector'; import { ManageDomains } from '../ManageDomains'; +import { editDomainRedirects } from '../reducers/domainRedirects'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components @@ -10,10 +11,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); bottle.serviceFactory('ManageDomains', () => ManageDomains); - bottle.decorator('ManageDomains', connect([ 'domainsList' ], [ 'listDomains' ])); + bottle.decorator('ManageDomains', connect( + [ 'domainsList' ], + [ 'listDomains', 'filterDomains', 'editDomainRedirects' ], + )); // Actions bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient'); + bottle.serviceFactory('filterDomains', () => filterDomains); + bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient'); }; export default provideServices; diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index de135dd4..97225485 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/SearchBar.tsx @@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl return (
- listShortUrls({ ...shortUrlsListParams, searchTerm }) - } - /> + listShortUrls({ ...shortUrlsListParams, searchTerm })} />
diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index f1f6d900..12c3ae46 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -5,6 +5,7 @@ import { buildReducer, buildActionCreator } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; @@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action { result: ShortUrl; } -export interface CreateShortUrlFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlCreation = { result: null, saving: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), [CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), [CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }), @@ -53,7 +50,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => dispatch({ type: CREATE_SHORT_URL, result }); } catch (e) { - dispatch({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index 23561c18..2530c153 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types'; import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; @@ -24,17 +25,13 @@ export interface DeleteShortUrlAction extends Action { domain?: string | null; } -interface DeleteShortUrlErrorAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlDeletion = { shortCode: '', loading: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }), [DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }), [SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }), @@ -52,7 +49,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => await deleteShortUrl(shortCode, domain); dispatch({ type: SHORT_URL_DELETED, shortCode, domain }); } catch (e) { - dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/src/short-urls/reducers/shortUrlDetail.ts index 1b174f1d..f338e2c7 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/src/short-urls/reducers/shortUrlDetail.ts @@ -7,6 +7,7 @@ import { GetState } from '../../container/types'; import { shortUrlMatches } from '../helpers'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; @@ -25,16 +26,12 @@ export interface ShortUrlDetailAction extends Action { shortUrl: ShortUrl; } -export interface ShortUrlDetailFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlDetail = { loading: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }), [GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }), [GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }), @@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); } catch (e) { - dispatch({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); } }; diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 50537fb1..8777dd47 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; import { supportsTagsInPatch } from '../../utils/helpers/features'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; @@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action { shortUrl: ShortUrl; } -export interface ShortUrlEditionFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: ShortUrlEdition = { saving: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), [EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), [SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }), @@ -59,7 +56,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( dispatch({ shortUrl, type: SHORT_URL_EDITED }); } catch (e) { - dispatch({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); + dispatch({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index a29345b0..7b05d4bd 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -30,11 +30,11 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( forceListTags(); }, []); - const renderContent = () => { - if (tagsList.loading) { - return ; - } + if (tagsList.loading) { + return ; + } + const renderContent = () => { if (tagsList.error) { return ( @@ -73,7 +73,7 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( return ( <> - {!tagsList.loading && } + {renderContent()} ); diff --git a/src/tags/reducers/tagDelete.ts b/src/tags/reducers/tagDelete.ts index acdac7c2..8a3664dd 100644 --- a/src/tags/reducers/tagDelete.ts +++ b/src/tags/reducers/tagDelete.ts @@ -4,6 +4,7 @@ import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START'; @@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action { tag: string; } -export interface DeleteTagFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: TagDeletion = { deleting: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [DELETE_TAG_START]: () => ({ deleting: true, error: false }), [DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }), [DELETE_TAG]: () => ({ deleting: false, error: false }), @@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: await deleteTags([ tag ]); dispatch({ type: DELETE_TAG }); } catch (e) { - dispatch({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); + dispatch({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/tags/reducers/tagEdit.ts b/src/tags/reducers/tagEdit.ts index 3bc85c88..d28f0838 100644 --- a/src/tags/reducers/tagEdit.ts +++ b/src/tags/reducers/tagEdit.ts @@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; /* eslint-disable padding-line-between-statements */ export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START'; @@ -29,10 +30,6 @@ export interface EditTagAction extends Action { color: string; } -export interface EditTagFailedAction extends Action { - errorData?: ProblemDetailsError; -} - const initialState: TagEdition = { oldName: '', newName: '', @@ -40,7 +37,7 @@ const initialState: TagEdition = { error: false, }; -export default buildReducer({ +export default buildReducer({ [EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }), [EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }), [EDIT_TAG]: (_, action) => ({ @@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener colorGenerator.setColorForKey(newName, color); dispatch({ type: EDIT_TAG, oldName, newName }); } catch (e) { - dispatch({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) }); + dispatch({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) }); throw e; } diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index a58424cb..90bef1b1 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { CreateVisit, Stats } from '../../visits/types'; import { parseApiError } from '../../api/utils'; import { TagStats } from '../data'; +import { ApiErrorAction } from '../../api/types/actions'; import { DeleteTagAction, TAG_DELETED } from './tagDelete'; import { EditTagAction, TAG_EDITED } from './tagEdit'; @@ -34,20 +35,16 @@ interface ListTagsAction extends Action { stats: TagsStatsMap; } -interface ListTagsFailedAction extends Action { - errorData?: ProblemDetailsError; -} - interface FilterTagsAction extends Action { searchTerm: string; } -type ListTagsCombinedAction = ListTagsAction +type TagsCombinedAction = ListTagsAction & DeleteTagAction & CreateVisitsAction & EditTagAction & FilterTagsAction -& ListTagsFailedAction; +& ApiErrorAction; const initialState = { tags: [], @@ -83,7 +80,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O }, {}), ); -export default buildReducer({ +export default buildReducer({ [LIST_TAGS_START]: () => ({ ...initialState, loading: true }), [LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }), @@ -130,7 +127,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t dispatch({ tags, stats: processedStats, type: LIST_TAGS }); } catch (e) { - dispatch({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); + dispatch({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); } }; diff --git a/src/utils/FormGroupContainer.tsx b/src/utils/FormGroupContainer.tsx index 34913286..ce66b4c2 100644 --- a/src/utils/FormGroupContainer.tsx +++ b/src/utils/FormGroupContainer.tsx @@ -8,12 +8,14 @@ interface FormGroupContainerProps { id?: string; type?: InputType; required?: boolean; + placeholder?: string; + className?: string; } export const FormGroupContainer: FC = ( - { children, value, onChange, id = uuid(), type = 'text', required = true }, + { children, value, onChange, id = uuid(), type = 'text', required = true, placeholder, className = '' }, ) => ( -
+
@@ -23,6 +25,7 @@ export const FormGroupContainer: FC = ( id={id} value={value} required={required} + placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
diff --git a/src/utils/SearchField.tsx b/src/utils/SearchField.tsx index e571157d..c373ffc4 100644 --- a/src/utils/SearchField.tsx +++ b/src/utils/SearchField.tsx @@ -10,14 +10,11 @@ let timer: NodeJS.Timeout | null; interface SearchFieldProps { onChange: (value: string) => void; className?: string; - placeholder?: string; large?: boolean; noBorder?: boolean; } -const SearchField = ( - { onChange, className, placeholder = 'Search...', large = true, noBorder = false }: SearchFieldProps, -) => { +const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => { const [ searchTerm, setSearchTerm ] = useState(''); const resetTimer = () => { @@ -43,7 +40,7 @@ const SearchField = ( 'form-control-lg': large, 'search-field__input--no-border': noBorder, })} - placeholder={placeholder} + placeholder="Search..." value={searchTerm} onChange={(e) => searchTermChanged(e.target.value)} /> diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 850fd4b6..56a044ed 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -43,3 +43,5 @@ export type OptionalString = Optional; export type RecursivePartial = { [P in keyof T]?: RecursivePartial; }; + +export const nonEmptyValueOrNull = (value: T): T | null => isEmpty(value) ? null : value; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 666e4419..1fd42d02 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,8 +1,9 @@ import { flatten, prop, range, splitEvery } from 'ramda'; import { Action, Dispatch } from 'redux'; import { ShlinkPaginator, ShlinkVisits } from '../../api/types'; -import { Visit, VisitsLoadFailedAction } from '../types'; +import { Visit } from '../types'; import { parseApiError } from '../../api/utils'; +import { ApiErrorAction } from '../../api/types/actions'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -72,6 +73,6 @@ export const getVisitsWithLoader = async & { visits: V dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); } catch (e) { - dispatch({ type: actionMap.error, errorData: parseApiError(e) }); + dispatch({ type: actionMap.error, errorData: parseApiError(e) }); } }; diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index ac77a5c5..08d5320d 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,17 +1,11 @@ import { Action, Dispatch } from 'redux'; -import { - OrphanVisit, - OrphanVisitType, - Visit, - VisitsInfo, - VisitsLoadFailedAction, - VisitsLoadProgressChangedAction, -} from '../types'; +import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; import { isOrphanVisit } from '../types/helpers'; +import { ApiErrorAction } from '../../api/types/actions'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -31,7 +25,7 @@ export interface OrphanVisitsAction extends Action { type OrphanVisitsCombinedAction = OrphanVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction -& VisitsLoadFailedAction; +& ApiErrorAction; const initialState: VisitsInfo = { visits: [], diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 2018a66a..688b0812 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,11 +1,12 @@ import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types'; +import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { ShortUrlIdentifier } from '../../short-urls/data'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -27,7 +28,7 @@ interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction -& VisitsLoadFailedAction; +& ApiErrorAction; const initialState: ShortUrlVisits = { visits: [], diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 77cf31b3..cc8140ae 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,9 +1,10 @@ import { Action, Dispatch } from 'redux'; -import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types'; +import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -28,7 +29,7 @@ export interface TagVisitsAction extends Action { type TagsVisitsCombinedAction = TagVisitsAction & VisitsLoadProgressChangedAction & CreateVisitsAction -& VisitsLoadFailedAction; +& ApiErrorAction; const initialState: TagVisits = { visits: [], diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 60c64a14..2a4de853 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -17,10 +17,6 @@ export interface VisitsLoadProgressChangedAction extends Action { progress: number; } -export interface VisitsLoadFailedAction extends Action { - errorData?: ProblemDetailsError; -} - export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 585bb1da..5bf9f4a3 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -13,20 +13,26 @@ describe('domainsList', () => { const domains = [ Mock.all(), Mock.all(), Mock.all() ]; describe('reducer', () => { - const action = (type: string, args: Partial = {}) => Mock.of( + const action = (type: string, args: Partial = {}): any => Mock.of( { type, ...args }, ); it('returns loading on LIST_DOMAINS_START', () => { - expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual({ domains: [], loading: true, error: false }); + expect(reducer(undefined, action(LIST_DOMAINS_START))).toEqual( + { domains: [], filteredDomains: [], loading: true, error: false }, + ); }); it('returns error on LIST_DOMAINS_ERROR', () => { - expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual({ domains: [], loading: false, error: true }); + expect(reducer(undefined, action(LIST_DOMAINS_ERROR))).toEqual( + { domains: [], filteredDomains: [], loading: false, error: true }, + ); }); it('returns domains on LIST_DOMAINS', () => { - expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual({ domains, loading: false, error: false }); + expect(reducer(undefined, action(LIST_DOMAINS, { domains }))).toEqual( + { domains, filteredDomains: domains, loading: false, error: false }, + ); }); }); diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index d276d933..8bf04278 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { determineOrderDir, rangeOf } from '../../src/utils/utils'; +import { determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; describe('utils', () => { describe('determineOrderDir', () => { @@ -47,4 +47,17 @@ describe('utils', () => { ]); }); }); + + describe('nonEmptyValueOrNull', () => { + it.each([ + [ '', null ], + [ 'Hello', 'Hello' ], + [[], null ], + [[ 1, 2, 3 ], [ 1, 2, 3 ]], + [{}, null ], + [{ foo: 'bar' }, { foo: 'bar' }], + ])('returns expected value based on input', (value, expected) => { + expect(nonEmptyValueOrNull(value)).toEqual(expected); + }); + }); }); From 8e71b2e2b156710235091035095c68b672253ebb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:00:58 +0200 Subject: [PATCH 04/17] Improved domainsList reducer test --- src/domains/DomainRow.tsx | 22 +++++----- src/domains/reducers/domainsList.ts | 4 +- test/domains/reducers/domainsList.test.ts | 50 +++++++++++++++++++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index 44c1de39..de7c3217 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -32,24 +32,26 @@ const DefaultDomain: FC = () => ( export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects }) => { const [ isOpen, toggle ] = useToggle(); + const { domain: authority, isDefault, redirects } = domain; + const domainId = `domainEdit${authority.replace('.', '')}`; return ( - {domain.isDefault ? : ''} - {domain.domain} - {domain.redirects?.baseUrlRedirect ?? } - {domain.redirects?.regular404Redirect ?? } + {isDefault ? : ''} + {authority} + {redirects?.baseUrlRedirect ?? } + {redirects?.regular404Redirect ?? } - {domain.redirects?.invalidShortUrlRedirect ?? } + {redirects?.invalidShortUrlRedirect ?? } - - - {domain.isDefault && ( - + {isDefault && ( + Redirects for default domain cannot be edited here.
Use config options or env vars. diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 6a5c80b0..910c2db5 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -37,12 +37,12 @@ const initialState: DomainsList = { error: false, }; -type DomainsCombinedAction = ListDomainsAction +export type DomainsCombinedAction = ListDomainsAction & ApiErrorAction & FilterDomainsAction & EditDomainRedirectsAction; -const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => +export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) => (d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects }; export default buildReducer({ diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 5bf9f4a3..5bc1eb9c 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -3,17 +3,23 @@ import reducer, { LIST_DOMAINS, LIST_DOMAINS_ERROR, LIST_DOMAINS_START, - ListDomainsAction, + FILTER_DOMAINS, + DomainsCombinedAction, + DomainsList, listDomains as listDomainsAction, + filterDomains as filterDomainsAction, + replaceRedirectsOnDomain, } from '../../../src/domains/reducers/domainsList'; -import { ShlinkDomain } from '../../../src/api/types'; +import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; describe('domainsList', () => { - const domains = [ Mock.all(), Mock.all(), Mock.all() ]; + const filteredDomains = [ Mock.of({ domain: 'foo' }), Mock.of({ domain: 'boo' }) ]; + const domains = [ ...filteredDomains, Mock.of({ domain: 'bar' }) ]; describe('reducer', () => { - const action = (type: string, args: Partial = {}): any => Mock.of( + const action = (type: string, args: Partial = {}) => Mock.of( { type, ...args }, ); @@ -34,6 +40,32 @@ describe('domainsList', () => { { domains, filteredDomains: domains, loading: false, error: false }, ); }); + + it('filters domains on FILTER_DOMAINS', () => { + expect(reducer(Mock.of({ domains }), action(FILTER_DOMAINS, { searchTerm: 'oo' }))).toEqual( + { domains, filteredDomains }, + ); + }); + + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'does_not_exist' ], + ])('replaces redirects on proper domain on EDIT_DOMAIN_REDIRECTS', (domain) => { + const redirects: ShlinkDomainRedirects = { + baseUrlRedirect: 'bar', + regular404Redirect: 'foo', + invalidShortUrlRedirect: null, + }; + + expect(reducer( + Mock.of({ domains, filteredDomains }), + action(EDIT_DOMAIN_REDIRECTS, { domain, redirects }), + )).toEqual({ + domains: domains.map(replaceRedirectsOnDomain(domain, redirects)), + filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)), + }); + }); }); describe('listDomains', () => { @@ -66,4 +98,14 @@ describe('domainsList', () => { expect(listDomains).toHaveBeenCalledTimes(1); }); }); + + describe('filterDomains', () => { + it.each([ + [ 'foo' ], + [ 'bar' ], + [ 'something' ], + ])('creates action as expected', (searchTerm) => { + expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm }); + }); + }); }); From dce1cefd496702cfc48aec186d097573282fcc38 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:06:18 +0200 Subject: [PATCH 05/17] Created domainRedirects reducer test --- test/domains/reducers/domainRedirects.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/domains/reducers/domainRedirects.test.ts diff --git a/test/domains/reducers/domainRedirects.test.ts b/test/domains/reducers/domainRedirects.test.ts new file mode 100644 index 00000000..3cb56393 --- /dev/null +++ b/test/domains/reducers/domainRedirects.test.ts @@ -0,0 +1,44 @@ +import { Mock } from 'ts-mockery'; +import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; +import { + EDIT_DOMAIN_REDIRECTS, + EDIT_DOMAIN_REDIRECTS_ERROR, + EDIT_DOMAIN_REDIRECTS_START, + editDomainRedirects as editDomainRedirectsAction, +} from '../../../src/domains/reducers/domainRedirects'; +import { ShlinkDomainRedirects } from '../../../src/api/types'; + +describe('domainRedirectsReducer', () => { + beforeEach(jest.clearAllMocks); + + describe('editDomainRedirects', () => { + const domain = 'example.com'; + const redirects = Mock.all(); + const dispatch = jest.fn(); + const getState = jest.fn(); + const editDomainRedirects = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ editDomainRedirects }); + + it('dispatches error when loading domains fails', async () => { + editDomainRedirects.mockRejectedValue(new Error('error')); + + await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS_ERROR }); + expect(editDomainRedirects).toHaveBeenCalledTimes(1); + }); + + it('dispatches domain and redirects once loaded', async () => { + editDomainRedirects.mockResolvedValue(redirects); + + await editDomainRedirectsAction(buildShlinkApiClient)(domain, {})(dispatch, getState); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_DOMAIN_REDIRECTS_START }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_DOMAIN_REDIRECTS, domain, redirects }); + expect(editDomainRedirects).toHaveBeenCalledTimes(1); + }); + }); +}); From d88f8221259714e0edea3c040701ed251fe7867d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:11:14 +0200 Subject: [PATCH 06/17] Extended ShlinkApiClient test covering editDomainRedirects --- test/api/services/ShlinkApiClient.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index a4a12d8f..95bf899f 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -297,4 +297,17 @@ describe('ShlinkApiClient', () => { expect(result).toEqual(expectedData); }); }); + + describe('editDomainRedirects', () => { + it('returns the redirects', async () => { + const resp = { baseUrlRedirect: null, regular404Redirect: 'foo', invalidShortUrlRedirect: 'bar' }; + const axiosSpy = createAxiosMock({ data: resp }); + const { editDomainRedirects } = new ShlinkApiClient(axiosSpy, '', ''); + + const result = await editDomainRedirects({ domain: 'foo' }); + + expect(axiosSpy).toHaveBeenCalled(); + expect(result).toEqual(resp); + }); + }); }); From f49b74229c3493fc26fa89adb31ba702e20d1176 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 09:34:56 +0200 Subject: [PATCH 07/17] Enhanced tooltip --- src/domains/DomainRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index de7c3217..24c3cab7 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -54,7 +54,7 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def Redirects for default domain cannot be edited here.
- Use config options or env vars. + Use config options or env vars directly on the server.
)} From b1d6f5861934c8500711a8f5350c172bd4970efb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 22 Aug 2021 10:46:47 +0200 Subject: [PATCH 08/17] Added responsiveness to manage domains table --- src/domains/DomainRow.tsx | 18 ++++++---- src/domains/ManageDomains.tsx | 2 +- src/index.scss | 1 + src/short-urls/ShortUrlsTable.scss | 8 ----- src/short-urls/ShortUrlsTable.tsx | 2 +- src/short-urls/helpers/ShortUrlsRow.scss | 31 ----------------- src/short-urls/helpers/ShortUrlsRow.tsx | 16 ++++----- src/utils/table/ResponsiveTable.scss | 42 ++++++++++++++++++++++++ 8 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 src/utils/table/ResponsiveTable.scss diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx index de7c3217..bf887f52 100644 --- a/src/domains/DomainRow.tsx +++ b/src/domains/DomainRow.tsx @@ -36,15 +36,19 @@ export const DomainRow: FC = ({ domain, editDomainRedirects, def const domainId = `domainEdit${authority.replace('.', '')}`; return ( - - {isDefault ? : ''} - {authority} - {redirects?.baseUrlRedirect ?? } - {redirects?.regular404Redirect ?? } - + + {isDefault ? : ''} + {authority} + + {redirects?.baseUrlRedirect ?? } + + + {redirects?.regular404Redirect ?? } + + {redirects?.invalidShortUrlRedirect ?? } - +