Implemented reducers for actions affecting short URLs list

This commit is contained in:
Alejandro Celaya 2020-12-08 10:57:27 +01:00
parent 17d5c4327b
commit 8d5f7e942d
6 changed files with 112 additions and 12 deletions

View file

@ -52,6 +52,10 @@ body,
white-space: nowrap; white-space: nowrap;
} }
.pointer {
cursor: pointer;
}
.text-ellipsis { .text-ellipsis {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;

View file

@ -202,7 +202,11 @@ const CreateShortUrl = (
</Button> </Button>
</div> </div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} /> <CreateShortUrlResult
{...shortUrlCreationResult}
resetCreateShortUrl={resetCreateShortUrl}
canBeClosed={basicMode}
/>
</form> </form>
); );
}; };

View file

@ -1,4 +1,5 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda'; import { isNil } from 'ramda';
import { useEffect } from 'react'; import { useEffect } from 'react';
@ -10,10 +11,11 @@ import './CreateShortUrlResult.scss';
export interface CreateShortUrlResultProps extends ShortUrlCreation { export interface CreateShortUrlResultProps extends ShortUrlCreation {
resetCreateShortUrl: () => void; resetCreateShortUrl: () => void;
canBeClosed: boolean;
} }
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => ( const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps, { error, result, resetCreateShortUrl, canBeClosed }: CreateShortUrlResultProps,
) => { ) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout(); const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
@ -38,6 +40,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return ( return (
<Card inverse className="bg-main mt-3"> <Card inverse className="bg-main mt-3">
<CardBody> <CardBody>
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
<b>Great!</b> The short URL is <b>{shortUrl}</b> <b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}> <CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>

View file

@ -18,7 +18,7 @@ export interface ShortUrlDeletion {
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;
} }
interface DeleteShortUrlAction extends Action<string> { export interface DeleteShortUrlAction extends Action<string> {
shortCode: string; shortCode: string;
domain?: string | null; domain?: string | null;
} }

View file

@ -1,4 +1,4 @@
import { assoc, assocPath, last, reject } from 'ramda'; import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../helpers'; import { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
@ -8,10 +8,11 @@ import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../utils/services/types'; import { ShlinkShortUrlsResponse } from '../../utils/services/types';
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta'; import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
import { ShortUrlsListParams } from './shortUrlsListParams'; import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@ -31,7 +32,13 @@ export interface ListShortUrlsAction extends Action<string> {
} }
export type ListShortUrlsCombinedAction = ( export type ListShortUrlsCombinedAction = (
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction ListShortUrlsAction
& EditShortUrlTagsAction
& ShortUrlEditedAction
& ShortUrlMetaEditedAction
& CreateVisitsAction
& CreateShortUrlAction
& DeleteShortUrlAction
); );
const initialState: ShortUrlsList = { const initialState: ShortUrlsList = {
@ -55,11 +62,18 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }), [LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath( [SHORT_URL_DELETED]: pipe(
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data), reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state, state,
), ),
(state) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ],
state.shortUrls.pagination.totalItems - 1,
state,
),
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'), [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlEditedAction>('longUrl'), [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlEditedAction>('longUrl'),
@ -77,6 +91,20 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
), ),
state, state,
), ),
[CREATE_SHORT_URL]: pipe(
// The only place where the list and the creation form coexist is the overview page.
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
[ result, ...init(state.shortUrls.data) ],
state,
),
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ],
state.shortUrls.pagination.totalItems + 1,
state,
),
),
}, initialState); }, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (

View file

@ -11,7 +11,9 @@ import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrl
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { ShlinkShortUrlsResponse } from '../../../src/utils/services/types'; import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/utils/services/types';
import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition';
describe('shortUrlsListReducer', () => { describe('shortUrlsListReducer', () => {
describe('reducer', () => { describe('reducer', () => {
@ -94,7 +96,7 @@ describe('shortUrlsListReducer', () => {
}); });
}); });
it('removes matching URL on SHORT_URL_DELETED', () => { it('removes matching URL and reduces total on SHORT_URL_DELETED', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const state = { const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({ shortUrls: Mock.of<ShlinkShortUrlsResponse>({
@ -103,6 +105,9 @@ describe('shortUrlsListReducer', () => {
Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }), Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }),
Mock.of<ShortUrl>({ shortCode: 'foo' }), Mock.of<ShortUrl>({ shortCode: 'foo' }),
], ],
pagination: Mock.of<ShlinkPaginator>({
totalItems: 10,
}),
}), }),
loading: false, loading: false,
error: false, error: false,
@ -111,6 +116,34 @@ describe('shortUrlsListReducer', () => {
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({ expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({
shortUrls: { shortUrls: {
data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }], data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
pagination: { totalItems: 9 },
},
loading: false,
error: false,
});
});
it('updates edited short URL on SHORT_URL_EDITED', () => {
const shortCode = 'abc123';
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: [
Mock.of<ShortUrl>({ shortCode, longUrl: 'old' }),
Mock.of<ShortUrl>({ shortCode, domain: 'example.com', longUrl: 'foo' }),
Mock.of<ShortUrl>({ shortCode: 'foo', longUrl: 'bar' }),
],
}),
loading: false,
error: false,
};
expect(reducer(state, { type: SHORT_URL_EDITED, shortCode, longUrl: 'newValue' } as any)).toEqual({
shortUrls: {
data: [
{ shortCode, longUrl: 'newValue' },
{ shortCode, longUrl: 'foo', domain: 'example.com' },
{ shortCode: 'foo', longUrl: 'bar' },
],
}, },
loading: false, loading: false,
error: false, error: false,
@ -147,6 +180,34 @@ describe('shortUrlsListReducer', () => {
error: false, error: false,
}); });
}); });
it('prepends new short URL and increases total on CREATE_SHORT_URL', () => {
const newShortUrl = Mock.of<ShortUrl>({ shortCode: 'newOne' });
const shortCode = 'abc123';
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: [
Mock.of<ShortUrl>({ shortCode }),
Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }),
Mock.of<ShortUrl>({ shortCode: 'foo' }),
],
pagination: Mock.of<ShlinkPaginator>({
totalItems: 15,
}),
}),
loading: false,
error: false,
};
expect(reducer(state, { type: CREATE_SHORT_URL, result: newShortUrl } as any)).toEqual({
shortUrls: {
data: [{ shortCode: 'newOne' }, { shortCode }, { shortCode, domain: 'example.com' }],
pagination: { totalItems: 16 },
},
loading: false,
error: false,
});
});
}); });
describe('listShortUrls', () => { describe('listShortUrls', () => {