From 8d5f7e942d1ab28439302d8650209991a1768e48 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 8 Dec 2020 10:57:27 +0100 Subject: [PATCH] Implemented reducers for actions affecting short URLs list --- src/index.scss | 4 ++ src/short-urls/CreateShortUrl.tsx | 6 +- .../helpers/CreateShortUrlResult.tsx | 5 +- src/short-urls/reducers/shortUrlDeletion.ts | 2 +- src/short-urls/reducers/shortUrlsList.ts | 42 ++++++++++-- .../short-urls/reducers/shortUrlsList.test.ts | 65 ++++++++++++++++++- 6 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/index.scss b/src/index.scss index b8243d0a..3d9909de 100644 --- a/src/index.scss +++ b/src/index.scss @@ -52,6 +52,10 @@ body, white-space: nowrap; } +.pointer { + cursor: pointer; +} + .text-ellipsis { text-overflow: ellipsis; overflow: hidden; diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 14b434ae..65c574a7 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -202,7 +202,11 @@ const CreateShortUrl = ( - + ); }; diff --git a/src/short-urls/helpers/CreateShortUrlResult.tsx b/src/short-urls/helpers/CreateShortUrlResult.tsx index 5c4c41ce..5dfdb971 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.tsx +++ b/src/short-urls/helpers/CreateShortUrlResult.tsx @@ -1,4 +1,5 @@ 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 { isNil } from 'ramda'; import { useEffect } from 'react'; @@ -10,10 +11,11 @@ import './CreateShortUrlResult.scss'; export interface CreateShortUrlResultProps extends ShortUrlCreation { resetCreateShortUrl: () => void; + canBeClosed: boolean; } const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => ( - { error, result, resetCreateShortUrl }: CreateShortUrlResultProps, + { error, result, resetCreateShortUrl, canBeClosed }: CreateShortUrlResultProps, ) => { const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout(); @@ -38,6 +40,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => ( return ( + {canBeClosed && } Great! The short URL is {shortUrl} diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index 86fa00ee..a21eb5e4 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -18,7 +18,7 @@ export interface ShortUrlDeletion { errorData?: ProblemDetailsError; } -interface DeleteShortUrlAction extends Action { +export interface DeleteShortUrlAction extends Action { shortCode: string; domain?: string | null; } diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index a07e50b4..dfba771c 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -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 { shortUrlMatches } from '../helpers'; import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; @@ -8,10 +8,11 @@ import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder'; import { ShlinkShortUrlsResponse } from '../../utils/services/types'; 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_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; import { ShortUrlsListParams } from './shortUrlsListParams'; +import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; /* eslint-disable padding-line-between-statements */ export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; @@ -31,7 +32,13 @@ export interface ListShortUrlsAction extends Action { } export type ListShortUrlsCombinedAction = ( - ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction + ListShortUrlsAction + & EditShortUrlTagsAction + & ShortUrlEditedAction + & ShortUrlMetaEditedAction + & CreateVisitsAction + & CreateShortUrlAction + & DeleteShortUrlAction ); const initialState: ShortUrlsList = { @@ -55,10 +62,17 @@ export default buildReducer({ [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), [LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }), - [SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath( - [ 'shortUrls', 'data' ], - reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data), - state, + [SHORT_URL_DELETED]: pipe( + (state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath( + [ 'shortUrls', 'data' ], + reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data), + state, + ), + (state) => !state.shortUrls ? state : assocPath( + [ 'shortUrls', 'pagination', 'totalItems' ], + state.shortUrls.pagination.totalItems - 1, + state, + ), ), [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), @@ -77,6 +91,20 @@ export default buildReducer({ ), 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); export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index e677b352..b9a6ed64 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -11,7 +11,9 @@ import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrl import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { ShortUrl } from '../../../src/short-urls/data'; 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('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 state = { shortUrls: Mock.of({ @@ -103,6 +105,9 @@ describe('shortUrlsListReducer', () => { Mock.of({ shortCode, domain: 'example.com' }), Mock.of({ shortCode: 'foo' }), ], + pagination: Mock.of({ + totalItems: 10, + }), }), loading: false, error: false, @@ -111,6 +116,34 @@ describe('shortUrlsListReducer', () => { expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({ shortUrls: { 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({ + data: [ + Mock.of({ shortCode, longUrl: 'old' }), + Mock.of({ shortCode, domain: 'example.com', longUrl: 'foo' }), + Mock.of({ 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, error: false, @@ -147,6 +180,34 @@ describe('shortUrlsListReducer', () => { error: false, }); }); + + it('prepends new short URL and increases total on CREATE_SHORT_URL', () => { + const newShortUrl = Mock.of({ shortCode: 'newOne' }); + const shortCode = 'abc123'; + const state = { + shortUrls: Mock.of({ + data: [ + Mock.of({ shortCode }), + Mock.of({ shortCode, domain: 'example.com' }), + Mock.of({ shortCode: 'foo' }), + ], + pagination: Mock.of({ + 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', () => {