Ensured all data can be set when editing a short URL

This commit is contained in:
Alejandro Celaya 2021-03-27 09:49:47 +01:00
parent a019bd30df
commit eea76d88c3
15 changed files with 73 additions and 64 deletions

View file

@ -12,7 +12,7 @@ import {
ShlinkTagsResponse,
ShlinkVisits,
ShlinkVisitsParams,
ShlinkShortUrlMeta,
ShlinkShortUrlData,
ShlinkDomain,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
@ -67,7 +67,7 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {});
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrlMeta instead */
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
public readonly updateShortUrlTags = async (
shortCode: string,
domain: OptionalString,
@ -76,12 +76,12 @@ export default class ShlinkApiClient {
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags);
public readonly updateShortUrlMeta = async (
public readonly updateShortUrl = async (
shortCode: string,
domain: OptionalString,
meta: ShlinkShortUrlMeta,
data: ShlinkShortUrlData,
): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data)
.then(({ data }) => data);
public readonly listTags = async (): Promise<ShlinkTags> =>

View file

@ -57,8 +57,10 @@ export interface ShlinkVisitsParams {
endDate?: string;
}
export interface ShlinkShortUrlMeta extends ShortUrlMeta {
export interface ShlinkShortUrlData extends ShortUrlMeta {
longUrl?: string;
title?: string;
validateUrl?: boolean;
tags?: string[];
}

View file

@ -9,13 +9,14 @@ import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { ShortUrl, ShortUrlData } from './data';
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail;
getShortUrlDetail: (shortCode: string, domain: OptionalString) => void;
editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise<void>;
}
const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
@ -44,6 +45,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
selectedServer,
shortUrlDetail,
getShortUrlDetail,
editShortUrl,
}: EditShortUrlConnectProps) => {
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { domain } = parseQuery<{ domain?: string }>(search);
@ -70,7 +72,7 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
saving={false}
selectedServer={selectedServer}
mode="edit"
onSave={async (shortUrlData) => Promise.resolve(console.log(shortUrlData))}
onSave={async (shortUrlData) => shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)}
/>
);
};

View file

@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import { isEmpty, pipe, replace, trim } from 'ramda';
import * as m from 'moment';
import m from 'moment';
import DateInput, { DateInputProps } from '../utils/DateInput';
import {
supportsListingDomains,
@ -46,8 +46,9 @@ export const ShortUrlForm = (
const reset = () => setShortUrlData(initialState);
const submit = handleEventPreventingDefault(async () => onSave({
...shortUrlData,
validSince: formatIsoDate(shortUrlData.validSince) ?? undefined,
validUntil: formatIsoDate(shortUrlData.validUntil) ?? undefined,
validSince: formatIsoDate(shortUrlData.validSince) ?? null,
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
}).then(() => !isEdit && reset()).catch(() => {}));
useEffect(() => {
@ -69,7 +70,7 @@ export const ShortUrlForm = (
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlData[id] as m.Moment | null}
selected={shortUrlData[id] ? m(shortUrlData[id]) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
@ -148,8 +149,8 @@ export const ShortUrlForm = (
<div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil as m.Moment | undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince as m.Moment | undefined })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })}
</SimpleCard>
</div>
</Row>

View file

@ -1,18 +1,22 @@
import * as m from 'moment';
import { Nullable, OptionalString } from '../../utils/utils';
export interface ShortUrlData {
longUrl: string;
export interface EditShortUrlData {
longUrl?: string;
tags?: string[];
customSlug?: string;
title?: string;
validSince?: m.Moment | string | null;
validUntil?: m.Moment | string | null;
maxVisits?: number | null;
validateUrl?: boolean;
}
export interface ShortUrlData extends EditShortUrlData {
longUrl: string;
customSlug?: string;
shortCodeLength?: number;
domain?: string;
validSince?: m.Moment | string;
validUntil?: m.Moment | string;
maxVisits?: number;
findIfExists?: boolean;
validateUrl?: boolean;
}
export interface ShortUrl {

View file

@ -3,13 +3,13 @@ import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button }
import { ExternalLink } from 'react-external-link';
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
import { ShortUrlModalProps } from '../data';
import { EditShortUrlData, ShortUrlModalProps } from '../data';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditShortUrlModalProps extends ShortUrlModalProps {
shortUrlEdition: ShortUrlEdition;
editShortUrl: (shortUrl: string, domain: OptionalString, longUrl: string) => Promise<void>;
editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise<void>;
}
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
@ -17,7 +17,7 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor
const url = shortUrl?.shortUrl ?? '';
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, { longUrl }).then(toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">

View file

@ -2,7 +2,7 @@ import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrlIdentifier } from '../data';
import { EditShortUrlData, ShortUrlIdentifier } from '../data';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
@ -45,13 +45,16 @@ export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEdit
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
shortCode: string,
domain: OptionalString,
longUrl: string,
data: EditShortUrlData,
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_SHORT_URL_START });
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
// TODO Pass tags to the updateTags function if server version is lower than 2.6
const { updateShortUrl } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, { longUrl });
const { longUrl } = await updateShortUrl(shortCode, domain, data as any); // FIXME Parse dates
dispatch<ShortUrlEditedAction>({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
} catch (e) {
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });

View file

@ -50,10 +50,10 @@ export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) =
meta: ShortUrlMeta,
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
const { updateShortUrl } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, meta);
await updateShortUrl(shortCode, domain, meta);
dispatch<ShortUrlMetaEditedAction>({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
} catch (e) {
dispatch<ShortUrlMetaEditionFailedAction>({ type: EDIT_SHORT_URL_META_ERROR, errorData: parseApiError(e) });

View file

@ -54,12 +54,12 @@ export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) =
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { selectedServer } = getState();
const tagsInPatch = supportsTagsInPatch(selectedServer);
const { updateShortUrlTags, updateShortUrlMeta } = buildShlinkApiClient(getState);
const { updateShortUrlTags, updateShortUrl } = buildShlinkApiClient(getState);
try {
const normalizedTags = await (
tagsInPatch
? updateShortUrlMeta(shortCode, domain, { tags }).then(prop('tags'))
? updateShortUrl(shortCode, domain, { tags }).then(prop('tags'))
: updateShortUrlTags(shortCode, domain, tags)
);

View file

@ -59,7 +59,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
bottle.decorator(
'EditShortUrl',
connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail' ]),
connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail', 'editShortUrl' ]),
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);

View file

@ -48,10 +48,7 @@ describe('ShlinkApiClient', () => {
const axiosSpy = createAxiosMock({ data: shortUrl });
const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
await createShortUrl(
// @ts-expect-error in case maxVisits is null, it needs to be ignored as if it was undefined
{ longUrl: 'bar', customSlug: undefined, maxVisits: null },
);
await createShortUrl({ longUrl: 'bar', customSlug: undefined, maxVisits: null });
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ data: { longUrl: 'bar' } }));
});
@ -139,7 +136,7 @@ describe('ShlinkApiClient', () => {
});
});
describe('updateShortUrlMeta', () => {
describe('updateShortUrl', () => {
it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => {
const meta = {
maxVisits: 50,
@ -147,9 +144,9 @@ describe('ShlinkApiClient', () => {
};
const expectedResp = Mock.of<ShortUrl>();
const axiosSpy = createAxiosMock({ data: expectedResp });
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy, '', '');
const { updateShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
const result = await updateShortUrlMeta(shortCode, domain, meta);
const result = await updateShortUrl(shortCode, domain, meta);
expect(expectedResp).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({

View file

@ -50,7 +50,7 @@ describe('<ShortUrlForm />', () => {
domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
maxVisits: 20,
findIfExists: false,
shortCodeLength: 15,
validateUrl: true,

View file

@ -42,19 +42,19 @@ describe('shortUrlEditionReducer', () => {
});
describe('editShortUrl', () => {
const updateShortUrlMeta = jest.fn().mockResolvedValue({});
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta });
const updateShortUrl = jest.fn().mockResolvedValue({ longUrl });
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl });
const dispatch = jest.fn();
const getState = () => Mock.of<ShlinkState>();
afterEach(jest.clearAllMocks);
it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches long URL on success', async (domain) => {
await editShortUrl(buildShlinkApiClient)(shortCode, domain, longUrl)(dispatch, getState);
await editShortUrl(buildShlinkApiClient)(shortCode, domain, { longUrl })(dispatch, getState);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, { longUrl });
expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, longUrl, shortCode, domain });
@ -63,17 +63,17 @@ describe('shortUrlEditionReducer', () => {
it('dispatches error on failure', async () => {
const error = new Error();
updateShortUrlMeta.mockRejectedValue(error);
updateShortUrl.mockRejectedValue(error);
try {
await editShortUrl(buildShlinkApiClient)(shortCode, undefined, longUrl)(dispatch, getState);
await editShortUrl(buildShlinkApiClient)(shortCode, undefined, { longUrl })(dispatch, getState);
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { longUrl });
expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_ERROR });

View file

@ -56,8 +56,8 @@ describe('shortUrlMetaReducer', () => {
});
describe('editShortUrlMeta', () => {
const updateShortUrlMeta = jest.fn().mockResolvedValue({});
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta });
const updateShortUrl = jest.fn().mockResolvedValue({});
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl });
const dispatch = jest.fn();
const getState = () => Mock.all<ShlinkState>();
@ -67,8 +67,8 @@ describe('shortUrlMetaReducer', () => {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch, getState);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, meta);
expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, meta);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode, domain });
@ -77,7 +77,7 @@ describe('shortUrlMetaReducer', () => {
it('dispatches error on failure', async () => {
const error = new Error();
updateShortUrlMeta.mockRejectedValue(error);
updateShortUrl.mockRejectedValue(error);
try {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch, getState);
@ -86,8 +86,8 @@ describe('shortUrlMetaReducer', () => {
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, meta);
expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, meta);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR });

View file

@ -61,8 +61,8 @@ describe('shortUrlTagsReducer', () => {
describe('editShortUrlTags', () => {
const updateShortUrlTags = jest.fn();
const updateShortUrlMeta = jest.fn();
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrlMeta });
const updateShortUrl = jest.fn();
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrl });
const dispatch = jest.fn();
const buildGetState = (selectedServer?: SelectedServer) => () => Mock.of<ShlinkState>({ selectedServer });
@ -78,7 +78,7 @@ describe('shortUrlTagsReducer', () => {
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags);
expect(updateShortUrlMeta).not.toHaveBeenCalled();
expect(updateShortUrl).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(
@ -87,10 +87,10 @@ describe('shortUrlTagsReducer', () => {
);
});
it('calls updateShortUrlMeta when server is version 2.6.0 or above', async () => {
it('calls updateShortUrl when server is version 2.6.0 or above', async () => {
const normalizedTags = [ 'bar', 'foo' ];
updateShortUrlMeta.mockResolvedValue({ tags: normalizedTags });
updateShortUrl.mockResolvedValue({ tags: normalizedTags });
await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(
dispatch,
@ -98,8 +98,8 @@ describe('shortUrlTagsReducer', () => {
);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { tags });
expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { tags });
expect(updateShortUrlTags).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });