diff --git a/CHANGELOG.md b/CHANGELOG.md index 76565154..3bbede9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added +* [#465](https://github.com/shlinkio/shlink-web-client/pull/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher. * [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title. * [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags. * [#463](https://github.com/shlinkio/shlink-web-client/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured: 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 acd0d4f7..b49e4338 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -65,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta { tags?: string[]; } +export interface ShlinkDomainRedirects { + baseUrlRedirect: string | null; + regular404Redirect: string | null; + invalidShortUrlRedirect: string | null; +} + +export interface ShlinkEditDomainRedirects extends Partial { + domain: 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/DomainRow.tsx b/src/domains/DomainRow.tsx new file mode 100644 index 00000000..5e5713e9 --- /dev/null +++ b/src/domains/DomainRow.tsx @@ -0,0 +1,73 @@ +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(); + const { domain: authority, isDefault, redirects } = domain; + const domainId = `domainEdit${authority.replace('.', '')}`; + + return ( + + {isDefault ? : ''} + {authority} + + {redirects?.baseUrlRedirect ?? } + + + {redirects?.regular404Redirect ?? } + + + {redirects?.invalidShortUrlRedirect ?? } + + + + + + {isDefault && ( + + Redirects for default domain cannot be edited here. +
+ Use config options or env vars directly on the server. +
+ )} + + + + ); +}; diff --git a/src/domains/ManageDomains.tsx b/src/domains/ManageDomains.tsx new file mode 100644 index 00000000..69f67a30 --- /dev/null +++ b/src/domains/ManageDomains.tsx @@ -0,0 +1,71 @@ +import { FC, useEffect } from 'react'; +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 headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ]; + +export const ManageDomains: FC = ( + { listDomains, domainsList, filterDomains, editDomainRedirects }, +) => { + const { filteredDomains: domains, loading, error, errorData } = domainsList; + const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects; + + useEffect(() => { + listDomains(); + }, []); + + if (loading) { + return ; + } + + const renderContent = () => { + if (error) { + return ( + + + + ); + } + + return ( + + + + {headers.map((column, index) => )} + + + {domains.length < 1 && } + {domains.map((domain) => ( + + ))} + +
{column}
No results found
+
+ ); + }; + + return ( + <> + + {renderContent()} + + ); +}; diff --git a/src/domains/helpers/EditDomainRedirectsModal.tsx b/src/domains/helpers/EditDomainRedirectsModal.tsx new file mode 100644 index 00000000..f4214ad7 --- /dev/null +++ b/src/domains/helpers/EditDomainRedirectsModal.tsx @@ -0,0 +1,72 @@ +import { FC, useState } from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types'; +import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer'; +import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils'; +import { InfoTooltip } from '../../utils/InfoTooltip'; + +interface EditDomainRedirectsModalProps { + domain: ShlinkDomain; + isOpen: boolean; + toggle: () => void; + editDomainRedirects: (domain: string, redirects: Partial) => Promise; +} + +const FormGroup: FC = ({ isLast, ...rest }) => ( + +); + +export const EditDomainRedirectsModal: FC = ( + { isOpen, toggle, domain, editDomainRedirects }, +) => { + const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? ''); + const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? ''); + const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState( + domain.redirects?.invalidShortUrlRedirect ?? '', + ); + 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..910c2db5 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({ +export type DomainsCombinedAction = ListDomainsAction +& ApiErrorAction +& FilterDomainsAction +& EditDomainRedirectsAction; + +export 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 bd56d8a2..e6f01b1b 100644 --- a/src/domains/services/provideServices.ts +++ b/src/domains/services/provideServices.ts @@ -1,15 +1,25 @@ 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 bottle.serviceFactory('DomainSelector', () => DomainSelector); bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ])); + bottle.serviceFactory('ManageDomains', () => ManageDomains); + 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/index.scss b/src/index.scss index 5a330475..5251b46e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -4,6 +4,7 @@ @import 'node_modules/bootstrap/scss/bootstrap.scss'; @import './common/react-tag-autocomplete.scss'; @import './theme/theme'; +@import './utils/table/ResponsiveTable'; * { outline: none !important; 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/servers/helpers/ServerForm.tsx b/src/servers/helpers/ServerForm.tsx index 78f04113..3d30b926 100644 --- a/src/servers/helpers/ServerForm.tsx +++ b/src/servers/helpers/ServerForm.tsx @@ -1,5 +1,5 @@ import { FC, ReactNode, useEffect, useState } from 'react'; -import { FormGroupContainer } from '../../utils/FormGroupContainer'; +import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer'; import { handleEventPreventingDefault } from '../../utils/utils'; import { ServerData } from '../data'; import { SimpleCard } from '../../utils/SimpleCard'; @@ -11,6 +11,9 @@ interface ServerFormProps { title?: ReactNode; } +const FormGroup: FC = (props) => + ; + export const ServerForm: FC = ({ onSubmit, initialValues, children, title }) => { const [ name, setName ] = useState(''); const [ url, setUrl ] = useState(''); @@ -26,9 +29,9 @@ export const ServerForm: FC = ({ onSubmit, initialValues, child return (
- Name - URL - API key + Name + URL + APIkey
{children}
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/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/short-urls/ShortUrlsTable.scss b/src/short-urls/ShortUrlsTable.scss index 0cf48290..70a18a20 100644 --- a/src/short-urls/ShortUrlsTable.scss +++ b/src/short-urls/ShortUrlsTable.scss @@ -1,11 +1,3 @@ -@import '../utils/base'; - -.short-urls-table__header { - @media (max-width: $responsiveTableBreakpoint) { - display: none; - } -} - .short-urls-table__header-cell--with-action { cursor: pointer; } diff --git a/src/short-urls/ShortUrlsTable.tsx b/src/short-urls/ShortUrlsTable.tsx index f1f6c05c..acf5d8fd 100644 --- a/src/short-urls/ShortUrlsTable.tsx +++ b/src/short-urls/ShortUrlsTable.tsx @@ -60,7 +60,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ return ( - + - + - - {shortUrl.title && ( - )} - - + - 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 fde6cc5e..7b05d4bd 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'; @@ -29,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 ( @@ -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,16 +67,16 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( ))}
))} -
+ ); }; return ( <> - {!tagsList.loading && } + {renderContent()} ); -}, () => [ Topics.visits() ]); +}, () => [ Topics.visits ]); export default TagsList; 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..d522ebfb 100644 --- a/src/utils/FormGroupContainer.tsx +++ b/src/utils/FormGroupContainer.tsx @@ -1,29 +1,37 @@ -import { FC } from 'react'; +import { FC, useRef } from 'react'; import { v4 as uuid } from 'uuid'; import { InputType } from 'reactstrap/lib/Input'; -interface FormGroupContainerProps { +export interface FormGroupContainerProps { value: string; onChange: (newValue: string) => void; id?: string; type?: InputType; required?: boolean; + placeholder?: string; + className?: string; + labelClassName?: string; } export const FormGroupContainer: FC = ( - { children, value, onChange, id = uuid(), type = 'text', required = true }, -) => ( -
- - onChange(e.target.value)} - /> -
-); + { children, value, onChange, id, type, required, placeholder, className, labelClassName }, +) => { + const forId = useRef(id ?? uuid()); + + return ( +
+ + onChange(e.target.value)} + /> +
+ ); +}; diff --git a/src/utils/InfoTooltip.tsx b/src/utils/InfoTooltip.tsx new file mode 100644 index 00000000..c7cffd25 --- /dev/null +++ b/src/utils/InfoTooltip.tsx @@ -0,0 +1,26 @@ +import { FC, useRef } from 'react'; +import * as Popper from 'popper.js'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; +import { UncontrolledTooltip } from 'reactstrap'; + +interface InfoTooltipProps { + className?: string; + placement: Popper.Placement; +} + +export const InfoTooltip: FC = ({ className = '', placement, children }) => { + const ref = useRef(); + const refCallback = (el: HTMLSpanElement) => { + ref.current = el; + }; + + return ( + <> + + + + ref.current) as any} placement={placement}>{children} + + ); +}; 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/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/utils/table/ResponsiveTable.scss b/src/utils/table/ResponsiveTable.scss new file mode 100644 index 00000000..40b6da02 --- /dev/null +++ b/src/utils/table/ResponsiveTable.scss @@ -0,0 +1,42 @@ +@import '../../utils/base'; + +.responsive-table__header { + @media (max-width: $responsiveTableBreakpoint) { + display: none; + } +} + +.responsive-table__row { + @media (max-width: $responsiveTableBreakpoint) { + display: block; + margin-bottom: 10px; + border-bottom: 1px solid var(--border-color); + position: relative; + } +} + +.responsive-table__cell.responsive-table__cell { + vertical-align: middle !important; + + @media (max-width: $responsiveTableBreakpoint) { + display: block; + width: 100%; + position: relative; + padding: .5rem; + font-size: .9rem; + + &[data-th]:before { + content: attr(data-th) ': '; + font-weight: 700; + } + + &:last-child { + position: absolute; + top: 3.5px; + right: .5rem; + width: auto; + padding: 0; + border: none; + } + } +} 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/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; 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/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); + }); + }); }); diff --git a/test/common/AsideMenu.test.tsx b/test/common/AsideMenu.test.tsx index 3eb9d918..22ef6a26 100644 --- a/test/common/AsideMenu.test.tsx +++ b/test/common/AsideMenu.test.tsx @@ -1,7 +1,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import asideMenuCreator from '../../src/common/AsideMenu'; -import { ServerWithId } from '../../src/servers/data'; +import { ReachableServer } from '../../src/servers/data'; describe('', () => { let wrapped: ShallowWrapper; @@ -10,7 +10,7 @@ describe('', () => { beforeEach(() => { const AsideMenu = asideMenuCreator(DeleteServerButton); - wrapped = shallow(({ id: 'abc123' })} />); + wrapped = shallow(({ id: 'abc123' })} />); }); afterEach(() => wrapped.unmount()); diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx index 1177b0e1..439bb81f 100644 --- a/test/common/MenuLayout.test.tsx +++ b/test/common/MenuLayout.test.tsx @@ -11,7 +11,7 @@ import { SemVer } from '../../src/utils/helpers/version'; describe('', () => { const ServerError = jest.fn(); const C = jest.fn(); - const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C); + const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C, C); let wrapper: ShallowWrapper; const createWrapper = (selectedServer: SelectedServer) => { wrapper = shallow( diff --git a/test/domains/DomainRow.test.tsx b/test/domains/DomainRow.test.tsx new file mode 100644 index 00000000..6fdc3923 --- /dev/null +++ b/test/domains/DomainRow.test.tsx @@ -0,0 +1,57 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { Button, UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons'; +import { ShlinkDomain, ShlinkDomainRedirects } from '../../src/api/types'; +import { DomainRow } from '../../src/domains/DomainRow'; + +describe('', () => { + let wrapper: ShallowWrapper; + const createWrapper = (domain: ShlinkDomain) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + it.each([ + [ Mock.of({ domain: '', isDefault: true }), 1 ], + [ Mock.of({ domain: '', isDefault: false }), 0 ], + ])('shows proper components based on the fact that provided domain is default or not', (domain, expectedComps) => { + const wrapper = createWrapper(domain); + const defaultDomainComp = wrapper.find('td').first().find('DefaultDomain'); + const tooltip = wrapper.find(UncontrolledTooltip); + const button = wrapper.find(Button); + const icon = wrapper.find(FontAwesomeIcon); + + expect(defaultDomainComp).toHaveLength(expectedComps); + expect(tooltip).toHaveLength(expectedComps); + expect(button.prop('disabled')).toEqual(domain.isDefault); + expect(icon.prop('icon')).toEqual(domain.isDefault ? forbiddenIcon : editIcon); + }); + + it.each([ + [ undefined, 3 ], + [ Mock.of(), 3 ], + [ Mock.of({ baseUrlRedirect: 'foo' }), 2 ], + [ Mock.of({ invalidShortUrlRedirect: 'foo' }), 2 ], + [ Mock.of({ baseUrlRedirect: 'foo', regular404Redirect: 'foo' }), 1 ], + [ + Mock.of( + { baseUrlRedirect: 'foo', regular404Redirect: 'foo', invalidShortUrlRedirect: 'foo' }, + ), + 0, + ], + ])('shows expected redirects', (redirects, expectedNoRedirects) => { + const wrapper = createWrapper(Mock.of({ domain: '', isDefault: true, redirects })); + const noRedirects = wrapper.find('Nr'); + const cells = wrapper.find('td'); + + expect(noRedirects).toHaveLength(expectedNoRedirects); + redirects?.baseUrlRedirect && expect(cells.at(1).html()).toContain(redirects.baseUrlRedirect); + redirects?.regular404Redirect && expect(cells.at(2).html()).toContain(redirects.regular404Redirect); + redirects?.invalidShortUrlRedirect && expect(cells.at(3).html()).toContain(redirects.invalidShortUrlRedirect); + }); +}); diff --git a/test/domains/ManageDomains.test.tsx b/test/domains/ManageDomains.test.tsx new file mode 100644 index 00000000..d5006935 --- /dev/null +++ b/test/domains/ManageDomains.test.tsx @@ -0,0 +1,106 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { DomainsList } from '../../src/domains/reducers/domainsList'; +import { ManageDomains } from '../../src/domains/ManageDomains'; +import Message from '../../src/utils/Message'; +import { Result } from '../../src/utils/Result'; +import SearchField from '../../src/utils/SearchField'; +import { ProblemDetailsError, ShlinkDomain } from '../../src/api/types'; +import { ShlinkApiError } from '../../src/api/ShlinkApiError'; +import { DomainRow } from '../../src/domains/DomainRow'; + +describe('', () => { + const listDomains = jest.fn(); + const filterDomains = jest.fn(); + const editDomainRedirects = jest.fn(); + let wrapper: ShallowWrapper; + const createWrapper = (domainsList: DomainsList) => { + wrapper = shallow( + , + ); + + return wrapper; + }; + + afterEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it('shows loading message while domains are loading', () => { + const wrapper = createWrapper(Mock.of({ loading: true, filteredDomains: [] })); + const message = wrapper.find(Message); + const searchField = wrapper.find(SearchField); + const result = wrapper.find(Result); + const apiError = wrapper.find(ShlinkApiError); + + expect(message).toHaveLength(1); + expect(message.prop('loading')).toEqual(true); + expect(searchField).toHaveLength(0); + expect(result).toHaveLength(0); + expect(apiError).toHaveLength(0); + }); + + it('shows error result when domains loading fails', () => { + const errorData = Mock.of(); + const wrapper = createWrapper(Mock.of( + { loading: false, error: true, errorData, filteredDomains: [] }, + )); + const message = wrapper.find(Message); + const searchField = wrapper.find(SearchField); + const result = wrapper.find(Result); + const apiError = wrapper.find(ShlinkApiError); + + expect(result).toHaveLength(1); + expect(result.prop('type')).toEqual('error'); + expect(apiError).toHaveLength(1); + expect(apiError.prop('errorData')).toEqual(errorData); + expect(searchField).toHaveLength(1); + expect(message).toHaveLength(0); + }); + + it('filters domains when SearchField changes', () => { + const wrapper = createWrapper(Mock.of({ loading: false, error: false, filteredDomains: [] })); + const searchField = wrapper.find(SearchField); + + expect(filterDomains).not.toHaveBeenCalled(); + searchField.simulate('change'); + expect(filterDomains).toHaveBeenCalledTimes(1); + }); + + it('shows expected headers', () => { + const wrapper = createWrapper(Mock.of({ loading: false, error: false, filteredDomains: [] })); + const headerCells = wrapper.find('th'); + + expect(headerCells).toHaveLength(6); + }); + + it('one row when list of domains is empty', () => { + const wrapper = createWrapper(Mock.of({ loading: false, error: false, filteredDomains: [] })); + const tableBody = wrapper.find('tbody'); + const regularRows = tableBody.find('tr'); + const domainRows = tableBody.find(DomainRow); + + expect(regularRows).toHaveLength(1); + expect(regularRows.html()).toContain('No results found'); + expect(domainRows).toHaveLength(0); + }); + + it('as many DomainRows as domains are provided', () => { + const filteredDomains = [ + Mock.of({ domain: 'foo' }), + Mock.of({ domain: 'bar' }), + Mock.of({ domain: 'baz' }), + ]; + const wrapper = createWrapper(Mock.of({ loading: false, error: false, filteredDomains })); + const tableBody = wrapper.find('tbody'); + const regularRows = tableBody.find('tr'); + const domainRows = tableBody.find(DomainRow); + + expect(regularRows).toHaveLength(0); + expect(domainRows).toHaveLength(filteredDomains.length); + }); +}); diff --git a/test/domains/helpers/EditDomainRedirectsModal.test.tsx b/test/domains/helpers/EditDomainRedirectsModal.test.tsx new file mode 100644 index 00000000..2293a807 --- /dev/null +++ b/test/domains/helpers/EditDomainRedirectsModal.test.tsx @@ -0,0 +1,95 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { Mock } from 'ts-mockery'; +import { Button, ModalHeader } from 'reactstrap'; +import { ShlinkDomain } from '../../../src/api/types'; +import { EditDomainRedirectsModal } from '../../../src/domains/helpers/EditDomainRedirectsModal'; +import { InfoTooltip } from '../../../src/utils/InfoTooltip'; + +describe('', () => { + let wrapper: ShallowWrapper; + const editDomainRedirects = jest.fn().mockResolvedValue(undefined); + const toggle = jest.fn(); + const domain = Mock.of({ + domain: 'foo.com', + redirects: { + baseUrlRedirect: 'baz', + }, + }); + + beforeEach(() => { + wrapper = shallow( + , + ); + }); + + afterEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it('renders domain in header', () => { + const header = wrapper.find(ModalHeader); + + expect(header.html()).toContain('foo.com'); + }); + + it('expected amount of form groups and tooltips', () => { + const formGroups = wrapper.find('FormGroup'); + const tooltips = wrapper.find(InfoTooltip); + + expect(formGroups).toHaveLength(3); + expect(tooltips).toHaveLength(3); + }); + + it('has different handlers to toggle the modal', () => { + expect(toggle).not.toHaveBeenCalled(); + + (wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion + (wrapper.find(ModalHeader).prop('toggle') as Function)(); + wrapper.find(Button).first().simulate('click'); + + expect(toggle).toHaveBeenCalledTimes(3); + }); + + it('saves expected values when form is submitted', () => { + const formGroups = wrapper.find('FormGroup'); + + expect(editDomainRedirects).not.toHaveBeenCalled(); + + wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); + expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + baseUrlRedirect: 'baz', + regular404Redirect: null, + invalidShortUrlRedirect: null, + }); + + formGroups.at(0).simulate('change', 'new_base_url'); + formGroups.at(2).simulate('change', 'new_invalid_short_url'); + + wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); + expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + baseUrlRedirect: 'new_base_url', + regular404Redirect: null, + invalidShortUrlRedirect: 'new_invalid_short_url', + }); + + formGroups.at(1).simulate('change', 'new_regular_404'); + formGroups.at(2).simulate('change', ''); + + wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); + expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + baseUrlRedirect: 'new_base_url', + regular404Redirect: 'new_regular_404', + invalidShortUrlRedirect: null, + }); + + formGroups.at(0).simulate('change', ''); + formGroups.at(1).simulate('change', ''); + formGroups.at(2).simulate('change', ''); + + wrapper.find('form').simulate('submit', { preventDefault: jest.fn() }); + expect(editDomainRedirects).toHaveBeenCalledWith('foo.com', { + baseUrlRedirect: null, + regular404Redirect: null, + invalidShortUrlRedirect: null, + }); + }); +}); 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); + }); + }); +}); diff --git a/test/domains/reducers/domainsList.test.ts b/test/domains/reducers/domainsList.test.ts index 585bb1da..5bc1eb9c 100644 --- a/test/domains/reducers/domainsList.test.ts +++ b/test/domains/reducers/domainsList.test.ts @@ -3,30 +3,68 @@ 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 = {}) => Mock.of( + const action = (type: string, args: Partial = {}) => 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 }, + ); + }); + + 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)), + }); }); }); @@ -60,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 }); + }); + }); }); diff --git a/test/servers/helpers/ServerForm.test.tsx b/test/servers/helpers/ServerForm.test.tsx index 3c40ed48..a891fe00 100644 --- a/test/servers/helpers/ServerForm.test.tsx +++ b/test/servers/helpers/ServerForm.test.tsx @@ -1,6 +1,5 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { ServerForm } from '../../../src/servers/helpers/ServerForm'; -import { FormGroupContainer } from '../../../src/utils/FormGroupContainer'; describe('', () => { let wrapper: ShallowWrapper; @@ -14,7 +13,7 @@ describe('', () => { afterEach(jest.resetAllMocks); it('renders components', () => { - expect(wrapper.find(FormGroupContainer)).toHaveLength(3); + expect(wrapper.find('FormGroup')).toHaveLength(3); expect(wrapper.find('span')).toHaveLength(1); }); diff --git a/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx b/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx index 76df7496..d8889b41 100644 --- a/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx +++ b/test/short-urls/helpers/ShortUrlFormCheckboxGroup.test.tsx @@ -1,6 +1,7 @@ import { shallow } from 'enzyme'; import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup'; import Checkbox from '../../../src/utils/Checkbox'; +import { InfoTooltip } from '../../../src/utils/InfoTooltip'; describe('', () => { test.each([ @@ -11,6 +12,6 @@ describe('', () => { const checkbox = wrapper.find(Checkbox); expect(checkbox.prop('className')).toEqual(expectedClassName); - expect(wrapper.find('InfoTooltip')).toHaveLength(expectedAmountOfTooltips); + expect(wrapper.find(InfoTooltip)).toHaveLength(expectedAmountOfTooltips); }); }); diff --git a/test/utils/InfoTooltip.test.tsx b/test/utils/InfoTooltip.test.tsx new file mode 100644 index 00000000..aacd574c --- /dev/null +++ b/test/utils/InfoTooltip.test.tsx @@ -0,0 +1,41 @@ +import { shallow } from 'enzyme'; +import { UncontrolledTooltip } from 'reactstrap'; +import Popper from 'popper.js'; +import { InfoTooltip } from '../../src/utils/InfoTooltip'; + +describe('', () => { + it.each([ + [ undefined ], + [ 'foo' ], + [ 'bar' ], + ])('renders expected className on span', (className) => { + const wrapper = shallow(); + const span = wrapper.find('span'); + + expect(span.prop('className')).toEqual(className ?? ''); + }); + + it.each([ + [ ], + [ 'Foo' ], + [ 'Hello' ], + [[ 'One', 'Two', ]], + ])('passes children down to the nested tooltip component', (children) => { + const wrapper = shallow({children}); + const tooltip = wrapper.find(UncontrolledTooltip); + + expect(tooltip.prop('children')).toEqual(children); + }); + + it.each([ + [ 'right' as Popper.Placement ], + [ 'left' as Popper.Placement ], + [ 'top' as Popper.Placement ], + [ 'bottom' as Popper.Placement ], + ])('places tooltip where requested', (placement) => { + const wrapper = shallow(); + const tooltip = wrapper.find(UncontrolledTooltip); + + expect(tooltip.prop('placement')).toEqual(placement); + }); +}); 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); + }); + }); });
Created at diff --git a/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx b/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx index 91dff0bc..410d7625 100644 --- a/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx +++ b/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx @@ -1,8 +1,6 @@ -import { ChangeEvent, FC, useRef } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; -import { UncontrolledTooltip } from 'reactstrap'; +import { ChangeEvent, FC } from 'react'; import Checkbox from '../../utils/Checkbox'; +import { InfoTooltip } from '../../utils/InfoTooltip'; interface ShortUrlFormCheckboxGroupProps { checked?: boolean; @@ -10,23 +8,6 @@ interface ShortUrlFormCheckboxGroupProps { infoTooltip?: string; } -const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => { - const ref = useRef(); - - return ( - <> - { - ref.current = el; - }} - > - - - ref.current) as any} placement="right">{tooltip} - - ); -}; - export const ShortUrlFormCheckboxGroup: FC = ( { children, infoTooltip, checked, onChange }, ) => ( @@ -34,6 +15,6 @@ export const ShortUrlFormCheckboxGroup: FC = ( {children} - {infoTooltip && } + {infoTooltip && {infoTooltip}}

); diff --git a/src/short-urls/helpers/ShortUrlsRow.scss b/src/short-urls/helpers/ShortUrlsRow.scss index a199b69b..92410c15 100644 --- a/src/short-urls/helpers/ShortUrlsRow.scss +++ b/src/short-urls/helpers/ShortUrlsRow.scss @@ -1,39 +1,8 @@ @import '../../utils/base'; @import '../../utils/mixins/vertical-align'; -.short-urls-row { - @media (max-width: $responsiveTableBreakpoint) { - display: block; - margin-bottom: 10px; - border-bottom: 1px solid var(--border-color); - position: relative; - } -} - .short-urls-row__cell.short-urls-row__cell { vertical-align: middle !important; - - @media (max-width: $responsiveTableBreakpoint) { - display: block; - width: 100%; - position: relative; - padding: .5rem; - font-size: .9rem; - - &:before { - content: attr(data-th); - font-weight: 700; - } - - &:last-child { - position: absolute; - top: 3.5px; - right: .5rem; - width: auto; - padding: 0; - border: none; - } - } } .short-urls-row__cell--break { diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index e04710c9..02ecd739 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -51,11 +51,11 @@ const ShortUrlsRow = ( }, [ shortUrl.visitsCount ]); return ( -
+
+ @@ -64,16 +64,16 @@ const ShortUrlsRow = ( + {shortUrl.title ?? shortUrl.longUrl} + {renderTags(shortUrl.tags)} + {renderTags(shortUrl.tags)} +