Created view to edit short URLs

This commit is contained in:
Alejandro Celaya 2021-03-20 16:32:12 +01:00
parent 631b46393b
commit a019bd30df
16 changed files with 190 additions and 61 deletions

View file

@ -21,6 +21,7 @@ const MenuLayout = (
OrphanVisits: FC,
ServerError: FC,
Overview: FC,
EditShortUrl: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
@ -50,6 +51,7 @@ const MenuLayout = (
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />

View file

@ -37,6 +37,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'OrphanVisits',
'ServerError',
'Overview',
'EditShortUrl',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);

View file

@ -1,4 +1,3 @@
import { pipe, replace, trim } from 'ramda';
import { FC, useMemo } from 'react';
import { SelectedServer } from '../servers/data';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
@ -19,8 +18,6 @@ interface CreateShortUrlConnectProps extends CreateShortUrlProps {
resetCreateShortUrl: () => void;
}
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
longUrl: '',
tags: [],

View file

@ -0,0 +1,76 @@
import { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import { SelectedServer } from '../servers/data';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { OptionalString } from '../utils/utils';
import { parseQuery } from '../utils/helpers/query';
import Message from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { ShortUrl, ShortUrlData } from './data';
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail;
getShortUrlDetail: (shortCode: string, domain: OptionalString) => void;
}
const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
const validateUrl = settings?.validateUrls ?? false;
if (!shortUrl) {
return { longUrl: '', validateUrl };
}
return {
longUrl: shortUrl.longUrl,
tags: shortUrl.tags,
title: shortUrl.title ?? undefined,
domain: shortUrl.domain ?? undefined,
validSince: shortUrl.meta.validSince ?? undefined,
validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined,
validateUrl,
};
};
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
match: { params },
location: { search },
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
shortUrlDetail,
getShortUrlDetail,
}: EditShortUrlConnectProps) => {
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { domain } = parseQuery<{ domain?: string }>(search);
useEffect(() => {
getShortUrlDetail(params.shortCode, domain);
}, []);
if (loading) {
return <Message loading />;
}
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading short URL detail :(" />
</Result>
);
}
return (
<ShortUrlForm
initialState={getInitialState(shortUrl, shortUrlCreationSettings)}
saving={false}
selectedServer={selectedServer}
mode="edit"
onSave={async (shortUrlData) => Promise.resolve(console.log(shortUrlData))}
/>
);
};

View file

@ -1,7 +1,7 @@
import { FC, useState } from 'react';
import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import { isEmpty } from 'ramda';
import { isEmpty, pipe, replace, trim } from 'ramda';
import * as m from 'moment';
import DateInput, { DateInputProps } from '../utils/DateInput';
import {
@ -18,7 +18,6 @@ import { Versions } from '../utils/helpers/version';
import { DomainSelectorProps } from '../domains/DomainSelector';
import { formatIsoDate } from '../utils/helpers/date';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { normalizeTag } from './CreateShortUrl';
import { ShortUrlData } from './data';
import './ShortUrlForm.scss';
@ -34,19 +33,26 @@ export interface ShortUrlFormProps {
selectedServer: SelectedServer;
}
const normalizeTag = pipe(trim, replace(/ /g, '-'));
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
ForServerVersion: FC<Versions>,
DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer, children }) => { // eslint-disable-line complexity
const [ shortUrlData, setShortUrlData ] = useState(initialState);
const isEdit = mode === 'edit';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlData(initialState);
const submit = handleEventPreventingDefault(async () => onSave({
...shortUrlData,
validSince: formatIsoDate(shortUrlData.validSince) ?? undefined,
validUntil: formatIsoDate(shortUrlData.validUntil) ?? undefined,
}).then(reset).catch(() => {}));
}).then(() => !isEdit && reset()).catch(() => {}));
useEffect(() => {
setShortUrlData(initialState);
}, [ initialState ]);
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
<FormGroup>
@ -54,7 +60,7 @@ export const ShortUrlForm = (
id={id}
type={type}
placeholder={placeholder}
value={shortUrlData[id]}
value={shortUrlData[id] ?? ''}
onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })}
{...props}
/>
@ -93,7 +99,6 @@ export const ShortUrlForm = (
const showDomainSelector = supportsListingDomains(selectedServer);
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
const supportsTitle = supportsShortUrlTitle(selectedServer);
const isEdit = mode === 'edit';
return (
<form className="short-url-form" onSubmit={submit}>
@ -191,7 +196,7 @@ export const ShortUrlForm = (
disabled={saving || isEmpty(shortUrlData.longUrl)}
className="btn-xs-block"
>
{saving ? 'Creating...' : 'Create'}
{saving ? 'Saving...' : 'Save'}
</Button>
</div>

View file

@ -0,0 +1,30 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data';
import { ShortUrl } from '../data';
export type LinkSuffix = 'visits' | 'edit';
export interface ShortUrlDetailLinkProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
suffix: LinkSuffix;
}
const buildUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl, suffix: LinkSuffix) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/${suffix}${query}`;
};
const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
{ selectedServer, shortUrl, suffix, children, ...rest },
) => {
if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildUrl(selectedServer, shortUrl, suffix)} {...rest}>{children}</Link>;
};
export default ShortUrlDetailLink;

View file

@ -4,10 +4,14 @@ import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import classNames from 'classnames';
import { prettify } from '../../utils/helpers/numbers';
import VisitStatsLink, { VisitStatsLinkProps } from './VisitStatsLink';
import { ShortUrl } from '../data';
import { SelectedServer } from '../../servers/data';
import ShortUrlDetailLink from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss';
interface ShortUrlVisitsCountProps extends VisitStatsLinkProps {
interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
visitsCount: number;
active?: boolean;
}
@ -15,13 +19,13 @@ interface ShortUrlVisitsCountProps extends VisitStatsLinkProps {
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps) => {
const maxVisits = shortUrl?.meta?.maxVisits;
const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<strong
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
>
{prettify(visitsCount)}
</strong>
</VisitStatsLink>
</ShortUrlDetailLink>
);
if (!maxVisits) {

View file

@ -14,7 +14,7 @@ import { useToggle } from '../../utils/helpers/hooks';
import { ShortUrl, ShortUrlModalProps } from '../data';
import { Versions } from '../../utils/helpers/version';
import { SelectedServer } from '../../servers/data';
import VisitStatsLink from './VisitStatsLink';
import ShortUrlDetailLink from './ShortUrlDetailLink';
import './ShortUrlsRowMenu.scss';
export interface ShortUrlsRowMenuProps {
@ -44,10 +44,14 @@ const ShortUrlsRowMenu = (
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu right>
<DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
</DropdownItem>
<DropdownItem onClick={toggleTags}>
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
</DropdownItem>

View file

@ -1,27 +0,0 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data';
import { ShortUrl } from '../data';
export interface VisitStatsLinkProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
}
const buildVisitsUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/visits${query}`;
};
const VisitStatsLink: FC<VisitStatsLinkProps & Record<string | number, any>> = (
{ selectedServer, shortUrl, children, ...rest },
) => {
if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
};
export default VisitStatsLink;

View file

@ -5,6 +5,8 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
import { OptionalString } from '../../utils/utils';
import { GetState } from '../../container/types';
import { shortUrlMatches } from '../helpers';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
@ -16,20 +18,25 @@ export interface ShortUrlDetail {
shortUrl?: ShortUrl;
loading: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface ShortUrlDetailAction extends Action<string> {
shortUrl: ShortUrl;
}
export interface ShortUrlDetailFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlDetail = {
loading: false,
error: false,
};
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction>({
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ShortUrlDetailFailedAction>({
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
[GET_SHORT_URL_DETAIL_ERROR]: () => ({ loading: false, error: true }),
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
}, initialState);
@ -47,6 +54,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) {
dispatch({ type: GET_SHORT_URL_DETAIL_ERROR });
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
}
};

View file

@ -21,6 +21,8 @@ import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable';
import QrCodeModal from '../helpers/QrCodeModal';
import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
@ -54,6 +56,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
);
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
bottle.decorator(
'EditShortUrl',
connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail' ]),
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
@ -89,6 +97,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
};

View file

@ -1,7 +1,6 @@
import Bottle from 'bottlejs';
import ShortUrlVisits from '../ShortUrlVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../../short-urls/reducers/shortUrlDetail';
import MapModal from '../helpers/MapModal';
import { createNewVisits } from '../reducers/visitCreation';
import TagVisits from '../TagVisits';
@ -41,7 +40,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');

View file

@ -11,7 +11,7 @@ import { SemVer } from '../../src/utils/helpers/version';
describe('<MenuLayout />', () => {
const ServerError = jest.fn();
const C = jest.fn();
const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C);
const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C);
let wrapper: ShallowWrapper;
const createWrapper = (selectedServer: SelectedServer) => {
wrapper = shallow(
@ -49,11 +49,11 @@ describe('<MenuLayout />', () => {
});
it.each([
[ '2.1.0' as SemVer, 6 ],
[ '2.2.0' as SemVer, 7 ],
[ '2.5.0' as SemVer, 7 ],
[ '2.6.0' as SemVer, 8 ],
[ '2.7.0' as SemVer, 8 ],
[ '2.1.0' as SemVer, 7 ],
[ '2.2.0' as SemVer, 8 ],
[ '2.5.0' as SemVer, 8 ],
[ '2.6.0' as SemVer, 9 ],
[ '2.7.0' as SemVer, 9 ],
])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => {
const selectedServer = Mock.of<ReachableServer>({ version });
const wrapper = createWrapper(selectedServer).dive();

View file

@ -1,11 +1,11 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Link } from 'react-router-dom';
import { Mock } from 'ts-mockery';
import VisitStatsLink from '../../../src/short-urls/helpers/VisitStatsLink';
import ShortUrlDetailLink, { LinkSuffix } from '../../../src/short-urls/helpers/ShortUrlDetailLink';
import { NotFoundServer, ReachableServer } from '../../../src/servers/data';
import { ShortUrl } from '../../../src/short-urls/data';
describe('<VisitStatsLink />', () => {
describe('<ShortUrlDetailLink />', () => {
let wrapper: ShallowWrapper;
afterEach(() => wrapper?.unmount());
@ -19,7 +19,11 @@ describe('<VisitStatsLink />', () => {
[ null, Mock.all<ShortUrl>() ],
[ undefined, Mock.all<ShortUrl>() ],
])('only renders a plain span when either server or short URL are not set', (selectedServer, shortUrl) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
wrapper = shallow(
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
Something
</ShortUrlDetailLink>,
);
const link = wrapper.find(Link);
expect(link).toHaveLength(0);
@ -30,15 +34,33 @@ describe('<VisitStatsLink />', () => {
[
Mock.of<ReachableServer>({ id: '1' }),
Mock.of<ShortUrl>({ shortCode: 'abc123' }),
'visits' as LinkSuffix,
'/server/1/short-code/abc123/visits',
],
[
Mock.of<ReachableServer>({ id: '3' }),
Mock.of<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }),
'visits' as LinkSuffix,
'/server/3/short-code/def456/visits?domain=example.com',
],
])('renders link with expected query when', (selectedServer, shortUrl, expectedLink) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>);
[
Mock.of<ReachableServer>({ id: '1' }),
Mock.of<ShortUrl>({ shortCode: 'abc123' }),
'edit' as LinkSuffix,
'/server/1/short-code/abc123/edit',
],
[
Mock.of<ReachableServer>({ id: '3' }),
Mock.of<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }),
'edit' as LinkSuffix,
'/server/3/short-code/def456/edit?domain=example.com',
],
])('renders link with expected query when', (selectedServer, shortUrl, suffix, expectedLink) => {
wrapper = shallow(
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix={suffix}>
Something
</ShortUrlDetailLink>,
);
const link = wrapper.find(Link);
const to = link.prop('to');

View file

@ -51,7 +51,7 @@ describe('<ShortUrlsRowMenu />', () => {
const wrapper = createWrapper();
const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(7);
expect(items).toHaveLength(8);
expect(items.find('[divider]')).toHaveLength(1);
});

View file

@ -51,7 +51,7 @@ describe('shortUrlDetailReducer', () => {
const buildGetState = (shortUrlsList?: ShortUrlsList) => () => Mock.of<ShlinkState>({ shortUrlsList });
it('dispatches start and error when promise is rejected', async () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject());
const ShlinkApiClient = buildApiClientMock(Promise.reject({}));
await getShortUrlDetail(() => ShlinkApiClient)('abc123', '')(dispatchMock, buildGetState());