diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 79ae02bb..2087b6b6 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -16,6 +16,8 @@ import { ShlinkDomain, ShlinkDomainsResponse, ShlinkVisitsOverview, + ShlinkEditDomainRedirects, + ShlinkDomainRedirects, } from '../types'; const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : ''; @@ -108,6 +110,11 @@ export default class ShlinkApiClient { public readonly listDomains = async (): Promise => this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data); + public readonly editDomainRedirects = async ( + domainRedirects: ShlinkEditDomainRedirects, + ): 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({ diff --git a/src/api/types/actions.ts b/src/api/types/actions.ts new file mode 100644 index 00000000..5359b6b3 --- /dev/null +++ b/src/api/types/actions.ts @@ -0,0 +1,6 @@ +import { Action } from 'redux'; +import { ProblemDetailsError } from './index'; + +export interface ApiErrorAction extends Action { + errorData?: ProblemDetailsError; +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 9d75eaf5..b49e4338 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -65,10 +65,14 @@ export interface ShlinkShortUrlData extends ShortUrlMeta { tags?: string[]; } -interface ShlinkDomainRedirects { - baseUrlRedirect: string, - regular404Redirect: string, - invalidShortUrlRedirect: string +export interface ShlinkDomainRedirects { + baseUrlRedirect: string | null; + regular404Redirect: string | null; + invalidShortUrlRedirect: string | null; +} + +export interface ShlinkEditDomainRedirects extends Partial { + domain: string; } export interface ShlinkDomain { diff --git a/src/domains/DomainRow.tsx b/src/domains/DomainRow.tsx new file mode 100644 index 00000000..44c1de39 --- /dev/null +++ b/src/domains/DomainRow.tsx @@ -0,0 +1,67 @@ +import { FC } from 'react'; +import { Button, UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faBan as forbiddenIcon, + faCheck as defaultDomainIcon, + faEdit as editIcon, +} from '@fortawesome/free-solid-svg-icons'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types'; +import { useToggle } from '../utils/helpers/hooks'; +import { OptionalString } from '../utils/utils'; +import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal'; + +interface DomainRowProps { + domain: ShlinkDomain; + defaultRedirects?: ShlinkDomainRedirects; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; +} + +const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => ( + + {!fallback && No redirect} + {fallback && <>{fallback} (as fallback)} + +); +const DefaultDomain: FC = () => ( + <> + + Default domain + +); + +export const DomainRow: FC = ({ domain, editDomainRedirects, defaultRedirects }) => { + const [ isOpen, toggle ] = useToggle(); + + return ( + + {domain.isDefault ? : ''} + {domain.domain} + {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); + }); + }); });