diff --git a/src/app/reducers/appUpdates.ts b/src/app/reducers/appUpdates.ts index 7a74d537..675b3959 100644 --- a/src/app/reducers/appUpdates.ts +++ b/src/app/reducers/appUpdates.ts @@ -1,7 +1,7 @@ import { createSlice } from '@reduxjs/toolkit'; const { actions, reducer } = createSlice({ - name: 'appUpdatesReducer', + name: 'shlink/appUpdates', initialState: false, reducers: { appUpdateAvailable: () => true, diff --git a/src/common/reducers/sidebar.ts b/src/common/reducers/sidebar.ts index c9f57a4c..2203ee43 100644 --- a/src/common/reducers/sidebar.ts +++ b/src/common/reducers/sidebar.ts @@ -9,7 +9,7 @@ const initialState: Sidebar = { }; const { actions, reducer } = createSlice({ - name: 'sidebarReducer', + name: 'shlink/sidebar', initialState, reducers: { sidebarPresent: () => ({ sidebarPresent: true }), diff --git a/src/domains/reducers/domainsList.ts b/src/domains/reducers/domainsList.ts index 77b8fe72..f1e622e0 100644 --- a/src/domains/reducers/domainsList.ts +++ b/src/domains/reducers/domainsList.ts @@ -9,9 +9,7 @@ import { ProblemDetailsError } from '../../api/types/errors'; import { parseApiError } from '../../api/utils'; import { EditDomainRedirects } from './domainRedirects'; -const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS'; -const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS'; -const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN'; +const REDUCER_PREFIX = 'shlink/domainsList'; export interface DomainsList { domains: Domain[]; @@ -49,7 +47,7 @@ export const domainsListReducerCreator = ( buildShlinkApiClient: ShlinkApiClientBuilder, editDomainRedirects: AsyncThunk, ) => { - const listDomains = createAsyncThunk(LIST_DOMAINS, async (_: void, { getState }): Promise => { + const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise => { const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState); const { data, defaultRedirects } = await shlinkListDomains(); @@ -60,7 +58,7 @@ export const domainsListReducerCreator = ( }); const checkDomainHealth = createAsyncThunk( - VALIDATE_DOMAIN, + `${REDUCER_PREFIX}/checkDomainHealth`, async (domain: string, { getState }): Promise => { const { selectedServer } = getState(); @@ -84,10 +82,10 @@ export const domainsListReducerCreator = ( }, ); - const filterDomains = createAction(FILTER_DOMAINS); + const filterDomains = createAction(`${REDUCER_PREFIX}/filterDomains`); const { reducer } = createSlice>({ - name: 'domainsList', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/index.scss b/src/index.scss index f9cb0643..ee9a8abb 100644 --- a/src/index.scss +++ b/src/index.scss @@ -3,7 +3,7 @@ @import './utils/base'; @import 'node_modules/bootstrap/scss/bootstrap.scss'; @import './common/react-tag-autocomplete.scss'; -@import './theme/theme'; +@import 'utils/theme/theme'; @import './utils/table/ResponsiveTable'; @import './utils/StickyCardPaginator'; diff --git a/src/mercure/reducers/mercureInfo.ts b/src/mercure/reducers/mercureInfo.ts index 75806cde..d9ec7974 100644 --- a/src/mercure/reducers/mercureInfo.ts +++ b/src/mercure/reducers/mercureInfo.ts @@ -3,7 +3,7 @@ import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkMercureInfo } from '../../api/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO'; +const REDUCER_PREFIX = 'shlink/mercure'; export interface MercureInfo extends Partial { interval?: number; @@ -17,17 +17,20 @@ const initialState: MercureInfo = { }; export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const loadMercureInfo = createAsyncThunk(GET_MERCURE_INFO, (_: void, { getState }): Promise => { - const { settings } = getState(); - if (!settings.realTimeUpdates.enabled) { - throw new Error('Real time updates not enabled'); - } + const loadMercureInfo = createAsyncThunk( + `${REDUCER_PREFIX}/loadMercureInfo`, + (_: void, { getState }): Promise => { + const { settings } = getState(); + if (!settings.realTimeUpdates.enabled) { + throw new Error('Real time updates not enabled'); + } - return buildShlinkApiClient(getState).mercureInfo(); - }); + return buildShlinkApiClient(getState).mercureInfo(); + }, + ); const { reducer } = createSlice({ - name: 'mercureInfoReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/reducers/index.ts b/src/reducers/index.ts index b91d1f9b..66ff189c 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,13 +2,11 @@ import { IContainer } from 'bottlejs'; import { combineReducers } from 'redux'; import { serversReducer } from '../servers/reducers/servers'; import selectedServerReducer from '../servers/reducers/selectedServer'; -import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; import domainVisitsReducer from '../visits/reducers/domainVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; -import tagsListReducer from '../tags/reducers/tagsList'; import { settingsReducer } from '../settings/reducers/settings'; import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; @@ -18,7 +16,7 @@ import { ShlinkState } from '../container/types'; export default (container: IContainer) => combineReducers({ servers: serversReducer, selectedServer: selectedServerReducer, - shortUrlsList: shortUrlsListReducer, + shortUrlsList: container.shortUrlsListReducer, shortUrlCreation: container.shortUrlCreationReducer, shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlEdition: container.shortUrlEditionReducer, @@ -28,7 +26,7 @@ export default (container: IContainer) => combineReducers({ domainVisits: domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, - tagsList: tagsListReducer, + tagsList: container.tagsListReducer, tagDelete: container.tagDeleteReducer, tagEdit: container.tagEditReducer, mercureInfo: container.mercureInfoReducer, diff --git a/src/servers/reducers/servers.ts b/src/servers/reducers/servers.ts index 0d5e7e0f..faa2c9c6 100644 --- a/src/servers/reducers/servers.ts +++ b/src/servers/reducers/servers.ts @@ -26,7 +26,7 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => { const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {}); export const { actions, reducer } = createSlice({ - name: 'serversReducer', + name: 'shlink/servers', initialState, reducers: { editServer: { diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index bfc4e2aa..7f66a67c 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -82,7 +82,7 @@ const toReducer = (prepare: SettingsPrepareAction) => ({ reducer: commonReducer, const toPreparedAction: SettingsPrepareAction = (payload: Settings) => ({ payload }); const { reducer, actions } = createSlice({ - name: 'settingsReducer', + name: 'shlink/settings', initialState, reducers: { toggleRealTimeUpdates: toReducer((enabled: boolean) => toPreparedAction({ realTimeUpdates: { enabled } })), diff --git a/src/short-urls/reducers/shortUrlCreation.ts b/src/short-urls/reducers/shortUrlCreation.ts index f30cdcd3..1a48b63e 100644 --- a/src/short-urls/reducers/shortUrlCreation.ts +++ b/src/short-urls/reducers/shortUrlCreation.ts @@ -5,7 +5,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL'; +const REDUCER_PREFIX = 'shlink/shortUrlCreation'; export type ShortUrlCreation = { saving: false; @@ -35,26 +35,29 @@ const initialState: ShortUrlCreation = { error: false, }; -export const shortUrlCreationReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const createShortUrl = createAsyncThunk(CREATE_SHORT_URL, (data: ShortUrlData, { getState }): Promise => { +export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/createShortUrl`, + (data: ShortUrlData, { getState }): Promise => { const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState); return shlinkCreateShortUrl(data); - }); + }, +); +export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType) => { const { reducer, actions } = createSlice({ - name: 'shortUrlCreationReducer', + name: REDUCER_PREFIX, initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting reducers: { resetCreateShortUrl: () => initialState, }, extraReducers: (builder) => { - builder.addCase(createShortUrl.pending, () => ({ saving: true, saved: false, error: false })); + builder.addCase(createShortUrlThunk.pending, () => ({ saving: true, saved: false, error: false })); builder.addCase( - createShortUrl.rejected, + createShortUrlThunk.rejected, (_, { error }) => ({ saving: false, saved: false, error: true, errorData: parseApiError(error) }), ); builder.addCase( - createShortUrl.fulfilled, + createShortUrlThunk.fulfilled, (_, { payload: result }) => ({ result, saving: false, saved: true, error: false }), ); }, @@ -64,7 +67,6 @@ export const shortUrlCreationReducerCreator = (buildShlinkApiClient: ShlinkApiCl return { reducer, - createShortUrl, resetCreateShortUrl, }; }; diff --git a/src/short-urls/reducers/shortUrlDeletion.ts b/src/short-urls/reducers/shortUrlDeletion.ts index 9af657b8..67d86499 100644 --- a/src/short-urls/reducers/shortUrlDeletion.ts +++ b/src/short-urls/reducers/shortUrlDeletion.ts @@ -1,11 +1,11 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; import { ShortUrlIdentifier } from '../data'; -export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED'; +const REDUCER_PREFIX = 'shlink/shortUrlDeletion'; export interface ShortUrlDeletion { shortCode: string; @@ -15,8 +15,6 @@ export interface ShortUrlDeletion { errorData?: ProblemDetailsError; } -export type DeleteShortUrlAction = PayloadAction; - const initialState: ShortUrlDeletion = { shortCode: '', loading: false, @@ -24,28 +22,31 @@ const initialState: ShortUrlDeletion = { error: false, }; -export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const deleteShortUrl = createAsyncThunk( - SHORT_URL_DELETED, - async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { - const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); - await shlinkDeleteShortUrl(shortCode, domain); - return { shortCode, domain }; - }, - ); +export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/deleteShortUrl`, + async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { + const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState); + await shlinkDeleteShortUrl(shortCode, domain); + return { shortCode, domain }; + }, +); +export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType) => { const { actions, reducer } = createSlice({ - name: 'shortUrlDeletion', + name: REDUCER_PREFIX, initialState, reducers: { resetDeleteShortUrl: () => initialState, }, extraReducers: (builder) => { - builder.addCase(deleteShortUrl.pending, (state) => ({ ...state, loading: true, error: false, deleted: false })); - builder.addCase(deleteShortUrl.rejected, (state, { error }) => ( + builder.addCase( + deleteShortUrlThunk.pending, + (state) => ({ ...state, loading: true, error: false, deleted: false }), + ); + builder.addCase(deleteShortUrlThunk.rejected, (state, { error }) => ( { ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false } )); - builder.addCase(deleteShortUrl.fulfilled, (state, { payload }) => ( + builder.addCase(deleteShortUrlThunk.fulfilled, (state, { payload }) => ( { ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true } )); }, @@ -53,5 +54,5 @@ export const shortUrlDeletionReducerCreator = (buildShlinkApiClient: ShlinkApiCl const { resetDeleteShortUrl } = actions; - return { reducer, deleteShortUrl, resetDeleteShortUrl }; + return { reducer, resetDeleteShortUrl }; }; diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/src/short-urls/reducers/shortUrlDetail.ts index b23591c6..4ad467f3 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/src/short-urls/reducers/shortUrlDetail.ts @@ -6,7 +6,7 @@ import { shortUrlMatches } from '../helpers'; import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL'; +const REDUCER_PREFIX = 'shlink/shortUrlDetail'; export interface ShortUrlDetail { shortUrl?: ShortUrl; @@ -24,7 +24,7 @@ const initialState: ShortUrlDetail = { export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { const getShortUrlDetail = createAsyncThunk( - GET_SHORT_URL_DETAIL, + `${REDUCER_PREFIX}/getShortUrlDetail`, async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise => { const { shortUrlsList } = getState(); const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain)); @@ -34,7 +34,7 @@ export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClie ); const { reducer } = createSlice({ - name: 'shortUrlDetailReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index fbdfac13..79ea145d 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -5,7 +5,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED'; +const REDUCER_PREFIX = 'shlink/shortUrlEdition'; export interface ShortUrlEdition { shortUrl?: ShortUrl; @@ -27,31 +27,27 @@ const initialState: ShortUrlEdition = { error: false, }; -export const shortUrlEditionReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const editShortUrl = createAsyncThunk( - SHORT_URL_EDITED, - ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise => { - const { updateShortUrl } = buildShlinkApiClient(getState); - return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates - }, - ); +export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/editShortUrl`, + ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise => { + const { updateShortUrl } = buildShlinkApiClient(getState); + return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates + }, +); - const { reducer } = createSlice({ - name: 'shortUrlEditionReducer', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(editShortUrl.pending, (state) => ({ ...state, saving: true, error: false, saved: false })); - builder.addCase( - editShortUrl.rejected, - (state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }), - ); - builder.addCase( - editShortUrl.fulfilled, - (_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }), - ); - }, - }); - - return { reducer, editShortUrl }; -}; +export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType) => createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(editShortUrlThunk.pending, (state) => ({ ...state, saving: true, error: false, saved: false })); + builder.addCase( + editShortUrlThunk.rejected, + (state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }), + ); + builder.addCase( + editShortUrlThunk.fulfilled, + (_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }), + ); + }, +}); diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 3e492bff..c6885353 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -1,20 +1,16 @@ +import { createSlice } from '@reduxjs/toolkit'; import { assoc, assocPath, last, pipe, reject } from 'ramda'; -import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../helpers'; -import { createNewVisits, CreateVisitsAction } from '../../visits/reducers/visitCreation'; -import { buildReducer } from '../../utils/helpers/redux'; -import { GetState } from '../../container/types'; +import { createNewVisits } from '../../visits/reducers/visitCreation'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types'; -import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; -import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; -import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; +import { deleteShortUrl } from './shortUrlDeletion'; +import { createShortUrl } from './shortUrlCreation'; +import { editShortUrl } from './shortUrlEdition'; import { ShortUrl } from '../data'; -export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START'; -export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR'; -export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS'; - +const REDUCER_PREFIX = 'shlink/shortUrlsList'; export const ITEMS_IN_OVERVIEW_PAGE = 5; export interface ShortUrlsList { @@ -23,96 +19,104 @@ export interface ShortUrlsList { error: boolean; } -export interface ListShortUrlsAction extends Action { - shortUrls: ShlinkShortUrlsResponse; -} - -export type ListShortUrlsCombinedAction = ( - ListShortUrlsAction - & CreateVisitsAction - & CreateShortUrlAction - & DeleteShortUrlAction - & ShortUrlEditedAction -); - const initialState: ShortUrlsList = { loading: true, error: false, }; -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}/fulfilled`]: pipe( // TODO Do not hardcode action type here - (state: ShortUrlsList, { payload }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'data'], - reject((shortUrl) => - shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data), - state, - )), - (state) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'pagination', 'totalItems'], - state.shortUrls.pagination.totalItems - 1, - state, - )), - ), - [createNewVisits.toString()]: (state, { payload }) => assocPath( - ['shortUrls', 'data'], - state.shortUrls?.data?.map( - (currentShortUrl) => { - // Find the last of the new visit for this short URL, and pick the amount of visits from it - const lastVisit = last( - payload.createdVisits.filter( - ({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain), - ), - ); +export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/listShortUrls`, + (params: ShlinkShortUrlsListParams | void, { getState }): Promise => { + const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState); + return shlinkListShortUrls(params ?? {}); + }, +); - return lastVisit?.shortUrl - ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) - : currentShortUrl; - }, - ), - state, - ), - [`${CREATE_SHORT_URL}/fulfilled`]: pipe( // TODO Do not hardcode action type here - // 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. - // We can also remove the items above the amount that is displayed there. - (state: ShortUrlsList, { payload }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'data'], - [payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)], - state, - )), - (state: ShortUrlsList) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'pagination', 'totalItems'], - state.shortUrls.pagination.totalItems + 1, - state, - )), - ), - // TODO Do not hardcode action type here - [`${SHORT_URL_EDITED}/fulfilled`]: (state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath( - ['shortUrls', 'data'], - state.shortUrls.data.map((shortUrl) => { - const { shortCode, domain } = editedShortUrl; +export const shortUrlsListReducerCreator = ( + listShortUrlsThunk: ReturnType, + editShortUrlThunk: ReturnType, + createShortUrlThunk: ReturnType, + deleteShortUrlThunk: ReturnType, +) => createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(listShortUrlsThunk.pending, (state) => ({ ...state, loading: true, error: false })); + builder.addCase(listShortUrlsThunk.rejected, () => ({ loading: false, error: true })); + builder.addCase( + listShortUrlsThunk.fulfilled, + (_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }), + ); - return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl; - }), - state, - )), -}, initialState); + builder.addCase( + createShortUrlThunk.fulfilled, + 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. + // We can also remove the items above the amount that is displayed there. + (state, { payload }) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'data'], + [payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)], + state, + )), + (state: ShortUrlsList) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'pagination', 'totalItems'], + state.shortUrls.pagination.totalItems + 1, + state, + )), + ), + ); -export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - params: ShlinkShortUrlsListParams = {}, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: LIST_SHORT_URLS_START }); - const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState); + builder.addCase( + editShortUrlThunk.fulfilled, + (state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'data'], + state.shortUrls.data.map((shortUrl) => { + const { shortCode, domain } = editedShortUrl; + return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl; + }), + state, + )), + ); - try { - const shortUrls = await shlinkListShortUrls(params); + builder.addCase( + deleteShortUrlThunk.fulfilled, + pipe( + (state, { payload }) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'data'], + reject((shortUrl) => + shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data), + state, + )), + (state) => (!state.shortUrls ? state : assocPath( + ['shortUrls', 'pagination', 'totalItems'], + state.shortUrls.pagination.totalItems - 1, + state, + )), + ), + ); - dispatch({ type: LIST_SHORT_URLS, shortUrls }); - } catch (e) { - dispatch({ type: LIST_SHORT_URLS_ERROR }); - } -}; + builder.addCase( + createNewVisits, + (state, { payload }) => assocPath( + ['shortUrls', 'data'], + state.shortUrls?.data?.map( + (currentShortUrl) => { + // Find the last of the new visit for this short URL, and pick the amount of visits from it + const lastVisit = last( + payload.createdVisits.filter( + ({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain), + ), + ); + + return lastVisit?.shortUrl + ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) + : currentShortUrl; + }, + ), + state, + ), + ); + }, +}); diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 8feb709b..4ddb4ea7 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -7,10 +7,10 @@ import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu'; import { CreateShortUrl } from '../CreateShortUrl'; import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal'; import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult'; -import { listShortUrls } from '../reducers/shortUrlsList'; -import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation'; -import { shortUrlDeletionReducerCreator } from '../reducers/shortUrlDeletion'; -import { shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; +import { listShortUrls, shortUrlsListReducerCreator } from '../reducers/shortUrlsList'; +import { shortUrlCreationReducerCreator, createShortUrl } from '../reducers/shortUrlCreation'; +import { shortUrlDeletionReducerCreator, deleteShortUrl } from '../reducers/shortUrlDeletion'; +import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition'; import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail'; import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; @@ -57,13 +57,23 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer'])); // Reducers - bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory( + 'shortUrlsListReducerCreator', + shortUrlsListReducerCreator, + 'listShortUrls', + 'editShortUrl', + 'createShortUrl', + 'deleteShortUrl', + ); + bottle.serviceFactory('shortUrlsListReducer', prop('reducer'), 'shortUrlsListReducerCreator'); + + bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl'); bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'editShortUrl'); bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator'); - bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'buildShlinkApiClient'); + bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl'); bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient'); @@ -72,15 +82,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Actions bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); - bottle.serviceFactory('createShortUrl', prop('createShortUrl'), 'shortUrlCreationReducerCreator'); + bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator'); - bottle.serviceFactory('deleteShortUrl', prop('deleteShortUrl'), 'shortUrlDeletionReducerCreator'); + bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator'); bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator'); - bottle.serviceFactory('editShortUrl', prop('editShortUrl'), 'shortUrlEditionReducerCreator'); + bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); }; export default provideServices; diff --git a/src/tags/reducers/tagDelete.ts b/src/tags/reducers/tagDelete.ts index bb7039bf..5bac50a8 100644 --- a/src/tags/reducers/tagDelete.ts +++ b/src/tags/reducers/tagDelete.ts @@ -1,11 +1,10 @@ -import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAction, createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG'; -const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED'; +const REDUCER_PREFIX = 'shlink/tagDelete'; export interface TagDeletion { deleting: boolean; @@ -14,24 +13,22 @@ export interface TagDeletion { errorData?: ProblemDetailsError; } -export type DeleteTagAction = PayloadAction; - const initialState: TagDeletion = { deleting: false, deleted: false, error: false, }; -export const tagDeleted = createAction(TAG_DELETED); +export const tagDeleted = createAction(`${REDUCER_PREFIX}/tagDeleted`); export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => { - const deleteTag = createAsyncThunk(DELETE_TAG, async (tag: string, { getState }): Promise => { + const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string, { getState }): Promise => { const { deleteTags } = buildShlinkApiClient(getState); await deleteTags([tag]); }); const { reducer } = createSlice({ - name: 'tagDeleteReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/tags/reducers/tagEdit.ts b/src/tags/reducers/tagEdit.ts index 087a8497..7c32b3ee 100644 --- a/src/tags/reducers/tagEdit.ts +++ b/src/tags/reducers/tagEdit.ts @@ -6,8 +6,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { parseApiError } from '../../api/utils'; import { ProblemDetailsError } from '../../api/types/errors'; -const EDIT_TAG = 'shlink/editTag/EDIT_TAG'; -const TAG_EDITED = 'shlink/editTag/TAG_EDITED'; +const REDUCER_PREFIX = 'shlink/tagEdit'; export interface TagEdition { oldName?: string; @@ -32,11 +31,11 @@ const initialState: TagEdition = { error: false, }; -export const tagEdited = createAction(TAG_EDITED); +export const tagEdited = createAction(`${REDUCER_PREFIX}/tagEdited`); export const tagEditReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGenerator: ColorGenerator) => { const editTag = createAsyncThunk( - EDIT_TAG, + `${REDUCER_PREFIX}/editTag`, async ({ oldName, newName, color }: EditTag, { getState }): Promise => { await buildShlinkApiClient(getState).editTag(oldName, newName); colorGenerator.setColorForKey(newName, color); @@ -46,7 +45,7 @@ export const tagEditReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuild ); const { reducer } = createSlice({ - name: 'tagEditReducer', + name: REDUCER_PREFIX, initialState, reducers: {}, extraReducers: (builder) => { diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index 5c60ab58..cdd55d26 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -1,23 +1,18 @@ +import { createAction, createSlice } from '@reduxjs/toolkit'; import { isEmpty, reject } from 'ramda'; -import { Action, Dispatch } from 'redux'; -import { createNewVisits, CreateVisitsAction } from '../../visits/reducers/visitCreation'; -import { buildReducer } from '../../utils/helpers/redux'; +import { createNewVisits } from '../../visits/reducers/visitCreation'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkTags } from '../../api/types'; -import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { CreateVisit, Stats } from '../../visits/types'; import { parseApiError } from '../../api/utils'; import { TagStats } from '../data'; -import { ApiErrorAction } from '../../api/types/actions'; -import { CREATE_SHORT_URL, CreateShortUrlAction } from '../../short-urls/reducers/shortUrlCreation'; -import { DeleteTagAction, tagDeleted } from './tagDelete'; -import { EditTagAction, tagEdited } from './tagEdit'; +import { createShortUrl } from '../../short-urls/reducers/shortUrlCreation'; +import { tagDeleted } from './tagDelete'; +import { tagEdited } from './tagEdit'; import { ProblemDetailsError } from '../../api/types/errors'; -export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START'; -export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR'; -export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS'; -export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS'; +const REDUCER_PREFIX = 'shlink/tagsList'; type TagsStatsMap = Record; @@ -30,24 +25,12 @@ export interface TagsList { errorData?: ProblemDetailsError; } -interface ListTagsAction extends Action { +interface ListTags { tags: string[]; stats: TagsStatsMap; } -interface FilterTagsAction extends Action { - searchTerm: string; -} - -type TagsCombinedAction = ListTagsAction -& DeleteTagAction -& CreateVisitsAction -& CreateShortUrlAction -& EditTagAction -& FilterTagsAction -& ApiErrorAction; - -const initialState = { +const initialState: TagsList = { tags: [], filteredTags: [], stats: {}, @@ -81,47 +64,15 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O }, {}), ); -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 }), - [tagDeleted.toString()]: (state, { payload: tag }) => ({ - ...state, - tags: rejectTag(state.tags, tag), - filteredTags: rejectTag(state.filteredTags, tag), - }), - [tagEdited.toString()]: (state, { payload }) => ({ - ...state, - tags: state.tags.map(renameTag(payload.oldName, payload.newName)).sort(), - filteredTags: state.filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(), - }), - [FILTER_TAGS]: (state, { searchTerm }) => ({ - ...state, - filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())), - }), - [createNewVisits.toString()]: (state, { payload }) => ({ - ...state, - stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats), - }), - [`${CREATE_SHORT_URL}/fulfilled`]: ({ tags: stateTags, ...rest }, { payload }) => ({ // TODO Do not hardcode action type here - ...rest, - tags: stateTags.concat(payload.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] - }), -}, initialState); +export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk( + `${REDUCER_PREFIX}/listTags`, + async (_: void, { getState }): Promise => { + const { tagsList } = getState(); -export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => () => async ( - dispatch: Dispatch, - getState: GetState, -) => { - const { tagsList } = getState(); + if (!force && !isEmpty(tagsList.tags)) { + return tagsList; + } - if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) { - return; - } - - dispatch({ type: LIST_TAGS_START }); - - try { const { listTags: shlinkListTags } = buildShlinkApiClient(getState); const { tags, stats = [] }: ShlinkTags = await shlinkListTags(); const processedStats = stats.reduce((acc, { tag, shortUrlsCount, visitsCount }) => { @@ -130,10 +81,51 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t return acc; }, {}); - dispatch({ tags, stats: processedStats, type: LIST_TAGS }); - } catch (e: any) { - dispatch({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) }); - } -}; + return { tags, stats: processedStats }; + }, +); -export const filterTags = (searchTerm: string): FilterTagsAction => ({ type: FILTER_TAGS, searchTerm }); +export const filterTags = createAction(`${REDUCER_PREFIX}/filterTags`); + +export const tagsListReducerCreator = ( + listTagsThunk: ReturnType, + createShortUrlThunk: ReturnType, +) => createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(filterTags, (state, { payload: searchTerm }) => ({ + ...state, + filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())), + })); + + builder.addCase(listTagsThunk.pending, (state) => ({ ...state, loading: true, error: false })); + builder.addCase(listTagsThunk.rejected, (_, { error }) => ( + { ...initialState, error: true, errorData: parseApiError(error) } + )); + builder.addCase(listTagsThunk.fulfilled, (_, { payload }) => ( + { ...initialState, stats: payload.stats, tags: payload.tags, filteredTags: payload.tags } + )); + + builder.addCase(tagDeleted, (state, { payload: tag }) => ({ + ...state, + tags: rejectTag(state.tags, tag), + filteredTags: rejectTag(state.filteredTags, tag), + })); + builder.addCase(tagEdited, (state, { payload }) => ({ + ...state, + tags: state.tags.map(renameTag(payload.oldName, payload.newName)).sort(), + filteredTags: state.filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(), + })); + builder.addCase(createNewVisits, (state, { payload }) => ({ + ...state, + stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats), + })); + + builder.addCase(createShortUrlThunk.fulfilled, ({ tags: stateTags, ...rest }, { payload }) => ({ + ...rest, + tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ] + })); + }, +}); diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index e73a8e2d..92980f72 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -5,7 +5,7 @@ import { TagCard } from '../TagCard'; import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal'; import { EditTagModal } from '../helpers/EditTagModal'; import { TagsList } from '../TagsList'; -import { filterTags, listTags } from '../reducers/tagsList'; +import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsList'; import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete'; import { tagEdited, tagEditReducerCreator } from '../reducers/tagEdit'; import { ConnectDecorator } from '../../container/types'; @@ -44,6 +44,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator'); + bottle.serviceFactory('tagsListReducerCreator', tagsListReducerCreator, 'listTags', 'createShortUrl'); + bottle.serviceFactory('tagsListReducer', prop('reducer'), 'tagsListReducerCreator'); + // Actions const listTagsActionFactory = (force: boolean) => ({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force); diff --git a/src/theme/theme.scss b/src/utils/theme/theme.scss similarity index 98% rename from src/theme/theme.scss rename to src/utils/theme/theme.scss index 7d797202..dd4c20d1 100644 --- a/src/theme/theme.scss +++ b/src/utils/theme/theme.scss @@ -1,4 +1,4 @@ -@import '../utils/base'; +@import '../base'; // Light theme colors $lightPrimaryColor: #ffffff; diff --git a/test/short-urls/reducers/shortUrlCreation.test.ts b/test/short-urls/reducers/shortUrlCreation.test.ts index e99e0ea2..f7815b4a 100644 --- a/test/short-urls/reducers/shortUrlCreation.test.ts +++ b/test/short-urls/reducers/shortUrlCreation.test.ts @@ -2,6 +2,7 @@ import { Mock } from 'ts-mockery'; import { CreateShortUrlAction, shortUrlCreationReducerCreator, + createShortUrl as createShortUrlCreator, } from '../../../src/short-urls/reducers/shortUrlCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; @@ -11,7 +12,8 @@ describe('shortUrlCreationReducer', () => { const shortUrl = Mock.of(); const createShortUrlCall = jest.fn(); const buildShlinkApiClient = () => Mock.of({ createShortUrl: createShortUrlCall }); - const { reducer, createShortUrl, resetCreateShortUrl } = shortUrlCreationReducerCreator(buildShlinkApiClient); + const createShortUrl = createShortUrlCreator(buildShlinkApiClient); + const { reducer, resetCreateShortUrl } = shortUrlCreationReducerCreator(createShortUrl); afterEach(jest.resetAllMocks); diff --git a/test/short-urls/reducers/shortUrlDeletion.test.ts b/test/short-urls/reducers/shortUrlDeletion.test.ts index fde38ff8..9a65734d 100644 --- a/test/short-urls/reducers/shortUrlDeletion.test.ts +++ b/test/short-urls/reducers/shortUrlDeletion.test.ts @@ -1,12 +1,16 @@ import { Mock } from 'ts-mockery'; -import { shortUrlDeletionReducerCreator } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { + shortUrlDeletionReducerCreator, + deleteShortUrl as deleteShortUrlCretor, +} from '../../../src/short-urls/reducers/shortUrlDeletion'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ProblemDetailsError } from '../../../src/api/types/errors'; describe('shortUrlDeletionReducer', () => { const deleteShortUrlCall = jest.fn(); const buildShlinkApiClient = () => Mock.of({ deleteShortUrl: deleteShortUrlCall }); - const { reducer, resetDeleteShortUrl, deleteShortUrl } = shortUrlDeletionReducerCreator(buildShlinkApiClient); + const deleteShortUrl = deleteShortUrlCretor(buildShlinkApiClient); + const { reducer, resetDeleteShortUrl } = shortUrlDeletionReducerCreator(deleteShortUrl); beforeEach(jest.clearAllMocks); diff --git a/test/short-urls/reducers/shortUrlEdition.test.ts b/test/short-urls/reducers/shortUrlEdition.test.ts index 7b0ecc43..941328c8 100644 --- a/test/short-urls/reducers/shortUrlEdition.test.ts +++ b/test/short-urls/reducers/shortUrlEdition.test.ts @@ -1,5 +1,9 @@ import { Mock } from 'ts-mockery'; -import { ShortUrlEditedAction, shortUrlEditionReducerCreator } from '../../../src/short-urls/reducers/shortUrlEdition'; +import { + ShortUrlEditedAction, + shortUrlEditionReducerCreator, + editShortUrl as editShortUrlCreator, +} from '../../../src/short-urls/reducers/shortUrlEdition'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; import { SelectedServer } from '../../../src/servers/data'; @@ -10,7 +14,8 @@ describe('shortUrlEditionReducer', () => { const shortUrl = Mock.of({ longUrl, shortCode }); const updateShortUrl = jest.fn().mockResolvedValue(shortUrl); const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); - const { reducer, editShortUrl } = shortUrlEditionReducerCreator(buildShlinkApiClient); + const editShortUrl = editShortUrlCreator(buildShlinkApiClient); + const { reducer } = shortUrlEditionReducerCreator(editShortUrl); afterEach(jest.clearAllMocks); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index 3eccb8d8..cc9e748d 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -1,37 +1,44 @@ import { Mock } from 'ts-mockery'; -import reducer, { - LIST_SHORT_URLS, - LIST_SHORT_URLS_ERROR, - LIST_SHORT_URLS_START, - listShortUrls, +import { + listShortUrls as listShortUrlsCreator, + shortUrlsListReducerCreator, } from '../../../src/short-urls/reducers/shortUrlsList'; -import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { deleteShortUrl as deleteShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlDeletion'; +import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types'; +import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; +import { editShortUrl as editShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlEdition'; +import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; -import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types'; -import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; -import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition'; -import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlsListReducer', () => { const shortCode = 'abc123'; + const listShortUrlsMock = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ listShortUrls: listShortUrlsMock }); + const listShortUrls = listShortUrlsCreator(buildShlinkApiClient); + const editShortUrl = editShortUrlCreator(buildShlinkApiClient); + const createShortUrl = createShortUrlCreator(buildShlinkApiClient); + const deleteShortUrl = deleteShortUrlCreator(buildShlinkApiClient); + const { reducer } = shortUrlsListReducerCreator(listShortUrls, editShortUrl, createShortUrl, deleteShortUrl); + + afterEach(jest.clearAllMocks); describe('reducer', () => { it('returns loading on LIST_SHORT_URLS_START', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS_START } as any)).toEqual({ + expect(reducer(undefined, { type: listShortUrls.pending.toString() })).toEqual({ loading: true, error: false, })); it('returns short URLs on LIST_SHORT_URLS', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS, shortUrls: { data: [] } } as any)).toEqual({ + expect(reducer(undefined, { type: listShortUrls.fulfilled.toString(), payload: { data: [] } })).toEqual({ shortUrls: { data: [] }, loading: false, error: false, })); it('returns error on LIST_SHORT_URLS_ERROR', () => - expect(reducer(undefined, { type: LIST_SHORT_URLS_ERROR } as any)).toEqual({ + expect(reducer(undefined, { type: listShortUrls.rejected.toString() })).toEqual({ loading: false, error: true, })); @@ -52,7 +59,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: `${SHORT_URL_DELETED}/fulfilled`, payload: { shortCode } } as any)).toEqual({ + expect(reducer(state, { type: deleteShortUrl.fulfilled.toString(), payload: { shortCode } })).toEqual({ shortUrls: { data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }], pagination: { totalItems: 9 }, @@ -85,7 +92,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: createNewVisits.toString(), payload: { createdVisits } } as any)).toEqual({ + expect(reducer(state, { type: createNewVisits.toString(), payload: { createdVisits } })).toEqual({ shortUrls: { data: [ { shortCode, domain: 'example.com', visitsCount: 5 }, @@ -142,7 +149,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: `${CREATE_SHORT_URL}/fulfilled`, payload: newShortUrl } as any)).toEqual({ + expect(reducer(state, { type: createShortUrl.fulfilled.toString(), payload: newShortUrl })).toEqual({ shortUrls: { data: expectedData, pagination: { totalItems: 16 }, @@ -181,7 +188,7 @@ describe('shortUrlsListReducer', () => { error: false, }; - const result = reducer(state, { type: `${SHORT_URL_EDITED}/fulfilled`, payload: editedShortUrl } as any); + const result = reducer(state, { type: editShortUrl.fulfilled.toString(), payload: editedShortUrl }); expect(result.shortUrls?.data).toEqual(expectedList); }); @@ -191,30 +198,29 @@ describe('shortUrlsListReducer', () => { const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue({ selectedServer: {} }); - afterEach(jest.clearAllMocks); - it('dispatches proper actions if API client request succeeds', async () => { - const listShortUrlsMock = jest.fn().mockResolvedValue([]); - const apiClientMock = Mock.of({ listShortUrls: listShortUrlsMock }); + listShortUrlsMock.mockResolvedValue({}); - await listShortUrls(() => apiClientMock)()(dispatch, getState); + await listShortUrls()(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS, shortUrls: [] }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listShortUrls.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: listShortUrls.fulfilled.toString(), + payload: {}, + })); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); it('dispatches proper actions if API client request fails', async () => { - const listShortUrlsMock = jest.fn().mockRejectedValue(undefined); - const apiClientMock = Mock.of({ listShortUrls: listShortUrlsMock }); + listShortUrlsMock.mockRejectedValue(undefined); - await listShortUrls(() => apiClientMock)()(dispatch, getState); + await listShortUrls()(dispatch, getState, {}); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_SHORT_URLS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_SHORT_URLS_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listShortUrls.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: listShortUrls.rejected.toString() })); expect(listShortUrlsMock).toHaveBeenCalledTimes(1); }); diff --git a/test/tags/reducers/tagsList.test.ts b/test/tags/reducers/tagsList.test.ts index 12ed7cb5..a3f1cbb2 100644 --- a/test/tags/reducers/tagsList.test.ts +++ b/test/tags/reducers/tagsList.test.ts @@ -1,32 +1,35 @@ import { Mock } from 'ts-mockery'; -import reducer, { - FILTER_TAGS, - filterTags, - LIST_TAGS, - LIST_TAGS_ERROR, - LIST_TAGS_START, - listTags, +import { TagsList, + filterTags, + listTags as listTagsCreator, + tagsListReducerCreator, } from '../../../src/tags/reducers/tagsList'; import { ShlinkState } from '../../../src/container/types'; import { ShortUrl } from '../../../src/short-urls/data'; -import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; +import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation'; import { tagEdited } from '../../../src/tags/reducers/tagEdit'; import { tagDeleted } from '../../../src/tags/reducers/tagDelete'; describe('tagsListReducer', () => { const state = (props: Partial) => Mock.of(props); + const buildShlinkApiClient = jest.fn(); + const listTags = listTagsCreator(buildShlinkApiClient, true); + const createShortUrl = createShortUrlCreator(buildShlinkApiClient); + const { reducer } = tagsListReducerCreator(listTags, createShortUrl); + + afterEach(jest.clearAllMocks); describe('reducer', () => { it('returns loading on LIST_TAGS_START', () => { - expect(reducer(undefined, { type: LIST_TAGS_START } as any)).toEqual(expect.objectContaining({ + expect(reducer(undefined, { type: listTags.pending.toString() })).toEqual(expect.objectContaining({ loading: true, error: false, })); }); it('returns error on LIST_TAGS_ERROR', () => { - expect(reducer(undefined, { type: LIST_TAGS_ERROR } as any)).toEqual(expect.objectContaining({ + expect(reducer(undefined, { type: listTags.rejected.toString() })).toEqual(expect.objectContaining({ loading: false, error: true, })); @@ -35,7 +38,10 @@ describe('tagsListReducer', () => { it('returns provided tags as filtered and regular tags on LIST_TAGS', () => { const tags = ['foo', 'bar', 'baz']; - expect(reducer(undefined, { type: LIST_TAGS, tags } as any)).toEqual({ + expect(reducer(undefined, { + type: listTags.fulfilled.toString(), + payload: { tags }, + })).toEqual({ tags, filteredTags: tags, loading: false, @@ -50,7 +56,7 @@ describe('tagsListReducer', () => { expect(reducer( state({ tags, filteredTags: tags }), - { type: tagDeleted.toString(), payload: tag } as any, + { type: tagDeleted.toString(), payload: tag }, )).toEqual({ tags: expectedTags, filteredTags: expectedTags, @@ -68,7 +74,7 @@ describe('tagsListReducer', () => { { type: tagEdited.toString(), payload: { oldName, newName }, - } as any, + }, )).toEqual({ tags: expectedTags, filteredTags: expectedTags, @@ -77,10 +83,10 @@ describe('tagsListReducer', () => { it('filters original list of tags by provided search term on FILTER_TAGS', () => { const tags = ['foo', 'bar', 'baz', 'Foo2', 'fo']; - const searchTerm = 'Fo'; + const payload = 'Fo'; const filteredTags = ['foo', 'Foo2', 'fo']; - expect(reducer(state({ tags }), { type: FILTER_TAGS, searchTerm } as any)).toEqual({ + expect(reducer(state({ tags }), { type: filterTags.toString(), payload })).toEqual({ tags, filteredTags, }); @@ -94,31 +100,28 @@ describe('tagsListReducer', () => { const tags = ['foo', 'bar', 'baz', 'foo2', 'fo']; const payload = Mock.of({ tags: shortUrlTags }); - expect(reducer(state({ tags }), { type: `${CREATE_SHORT_URL}/fulfilled`, payload } as any)).toEqual({ + expect(reducer(state({ tags }), { type: createShortUrl.fulfilled.toString(), payload })).toEqual({ tags: expectedTags, }); }); }); describe('filterTags', () => { - it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, searchTerm: 'foo' })); + it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: filterTags.toString(), payload: 'foo' })); }); describe('listTags', () => { const dispatch = jest.fn(); const getState = jest.fn(() => Mock.all()); - const buildShlinkApiClient = jest.fn(); const listTagsMock = jest.fn(); - afterEach(jest.clearAllMocks); - const assertNoAction = async (tagsList: TagsList) => { getState.mockReturnValue(Mock.of({ tagsList })); - await listTags(buildShlinkApiClient, false)()(dispatch, getState); + await listTagsCreator(buildShlinkApiClient, false)()(dispatch, getState, {}); expect(buildShlinkApiClient).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(2); expect(getState).toHaveBeenCalledTimes(1); }; @@ -134,23 +137,26 @@ describe('tagsListReducer', () => { listTagsMock.mockResolvedValue({ tags, stats: [] }); buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock }); - await listTags(buildShlinkApiClient, true)()(dispatch, getState); + await listTags()(dispatch, getState, {}); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_TAGS, tags, stats: {} }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listTags.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: listTags.fulfilled.toString(), + payload: { tags, stats: {} }, + })); }); const assertErrorResult = async () => { - await listTags(buildShlinkApiClient, true)()(dispatch, getState); + await listTags()(dispatch, getState, {}); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(getState).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_TAGS_ERROR }); + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: listTags.pending.toString() })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: listTags.rejected.toString() })); }; it('dispatches error when error occurs on list call', async () => { @@ -168,7 +174,6 @@ describe('tagsListReducer', () => { }); await assertErrorResult(); - expect(listTagsMock).not.toHaveBeenCalled(); }); });