Migrated editShortUrl reducer to RTK

This commit is contained in:
Alejandro Celaya 2022-11-06 12:32:55 +01:00
parent 77cbb8ebc4
commit 2a268de2cb
8 changed files with 74 additions and 66 deletions

View file

@ -4,7 +4,6 @@ import { serversReducer } from '../servers/reducers/servers';
import selectedServerReducer from '../servers/reducers/selectedServer'; import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits';
import domainVisitsReducer from '../visits/reducers/domainVisits'; import domainVisitsReducer from '../visits/reducers/domainVisits';
@ -26,7 +25,7 @@ export default (container: IContainer) => combineReducers<ShlinkState>({
shortUrlsList: shortUrlsListReducer, shortUrlsList: shortUrlsListReducer,
shortUrlCreationResult: container.shortUrlCreationReducer, shortUrlCreationResult: container.shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer, shortUrlDeletion: shortUrlDeletionReducer,
shortUrlEdition: shortUrlEditionReducer, shortUrlEdition: container.shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer, tagVisits: tagVisitsReducer,
domainVisits: domainVisitsReducer, domainVisits: domainVisitsReducer,

View file

@ -11,7 +11,7 @@ import { parseQuery } from '../utils/helpers/query';
import { Message } from '../utils/Message'; import { Message } from '../utils/Message';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { useGoBack, useToggle } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { ShortUrlFormProps } from './ShortUrlForm'; import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail'; import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition'; import { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
@ -23,7 +23,7 @@ interface EditShortUrlConnectProps {
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
getShortUrlDetail: (shortCode: string, domain: OptionalString) => void; getShortUrlDetail: (shortCode: string, domain: OptionalString) => void;
editShortUrl: (editShortUrl: EditShortUrlInfo) => Promise<void>; editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
} }
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
@ -38,13 +38,12 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
const params = useParams<{ shortCode: string }>(); const params = useParams<{ shortCode: string }>();
const goBack = useGoBack(); const goBack = useGoBack();
const { loading, error, errorData, shortUrl } = shortUrlDetail; const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition; const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
const initialState = useMemo( const initialState = useMemo(
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings), () => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
[shortUrl, shortUrlCreationSettings], [shortUrl, shortUrlCreationSettings],
); );
const [savingSucceeded,, isSuccessful, isNotSuccessful] = useToggle();
useEffect(() => { useEffect(() => {
params.shortCode && getShortUrlDetail(urlDecodeShortCode(params.shortCode), domain); params.shortCode && getShortUrlDetail(urlDecodeShortCode(params.shortCode), domain);
@ -87,18 +86,15 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
return; return;
} }
isNotSuccessful(); editShortUrl({ ...shortUrl, data: shortUrlData });
editShortUrl({ ...shortUrl, data: shortUrlData })
.then(isSuccessful)
.catch(isNotSuccessful);
}} }}
/> />
{savingError && ( {saved && savingError && (
<Result type="error" className="mt-3"> <Result type="error" className="mt-3">
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" /> <ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
</Result> </Result>
)} )}
{savingSucceeded && <Result type="success" className="mt-3">Short URL properly edited.</Result>} {saved && !savingError && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
</> </>
); );
}; };

View file

@ -1,22 +1,18 @@
import { PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Dispatch } from 'redux'; import { createAsyncThunk } from '../../utils/helpers/redux';
import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { EditShortUrlData, ShortUrl } from '../data'; import { EditShortUrlData, ShortUrl } from '../data';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
import { ProblemDetailsError } from '../../api/types/errors'; import { ProblemDetailsError } from '../../api/types/errors';
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED'; export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
export interface ShortUrlEdition { export interface ShortUrlEdition {
shortUrl?: ShortUrl; shortUrl?: ShortUrl;
saving: boolean; saving: boolean;
error: boolean; error: boolean;
saved: boolean;
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;
} }
@ -30,29 +26,35 @@ export type ShortUrlEditedAction = PayloadAction<ShortUrl>;
const initialState: ShortUrlEdition = { const initialState: ShortUrlEdition = {
saving: false, saving: false,
saved: false,
error: false, error: false,
}; };
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({ export const shortUrlEditionReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), const editShortUrl = createAsyncThunk(
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), SHORT_URL_EDITED,
[SHORT_URL_EDITED]: (_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false }), ({ shortCode, domain, data }: EditShortUrl, { getState }): Promise<ShortUrl> => {
}, initialState); const { updateShortUrl } = buildShlinkApiClient(getState);
return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates
},
);
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( const { reducer } = createSlice({
{ shortCode, domain, data }: EditShortUrl, name: 'shortUrlEditionReducer',
) => async (dispatch: Dispatch, getState: GetState) => { initialState,
dispatch({ type: EDIT_SHORT_URL_START }); 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 }),
);
},
});
const { updateShortUrl } = buildShlinkApiClient(getState); return { reducer, editShortUrl };
try {
const payload = await updateShortUrl(shortCode, domain, data as any); // FIXME parse dates;
dispatch<ShortUrlEditedAction>({ payload, type: SHORT_URL_EDITED });
} catch (e: any) {
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
throw e;
}
}; };

View file

@ -89,7 +89,7 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
state, state,
)), )),
), ),
[SHORT_URL_EDITED]: (state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath( [`${SHORT_URL_EDITED}/fulfilled`]: (state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
['shortUrls', 'data'], ['shortUrls', 'data'],
state.shortUrls.data.map((shortUrl) => { state.shortUrls.data.map((shortUrl) => {
const { shortCode, domain } = editedShortUrl; const { shortCode, domain } = editedShortUrl;

View file

@ -10,7 +10,7 @@ import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList'; import { listShortUrls } from '../reducers/shortUrlsList';
import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation'; import { shortUrlCreationReducerCreator } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion'; import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
import { editShortUrl } from '../reducers/shortUrlEdition'; import { shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable'; import { ShortUrlsTable } from '../ShortUrlsTable';
import { QrCodeModal } from '../helpers/QrCodeModal'; import { QrCodeModal } from '../helpers/QrCodeModal';
@ -60,6 +60,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'buildShlinkApiClient'); bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'buildShlinkApiClient');
bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator'); bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator');
bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'buildShlinkApiClient');
bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator');
// Actions // Actions
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
@ -71,7 +74,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrl', prop('editShortUrl'), 'shortUrlEditionReducerCreator');
}; };
export default provideServices; export default provideServices;

View file

@ -46,9 +46,16 @@ describe('<EditShortUrl />', () => {
}); });
it('shows error when saving data has failed', () => { it('shows error when saving data has failed', () => {
setUp({}, { error: true }); setUp({}, { error: true, saved: true });
expect(screen.getByText('An error occurred while updating short URL :(')).toBeInTheDocument(); expect(screen.getByText('An error occurred while updating short URL :(')).toBeInTheDocument();
expect(screen.getByText('ShortUrlForm')).toBeInTheDocument(); expect(screen.getByText('ShortUrlForm')).toBeInTheDocument();
}); });
it('shows message when saving data succeeds', () => {
setUp({}, { error: false, saved: true });
expect(screen.getByText('Short URL properly edited.')).toBeInTheDocument();
expect(screen.getByText('ShortUrlForm')).toBeInTheDocument();
});
}); });

View file

@ -1,11 +1,5 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import reducer, { import { ShortUrlEditedAction, shortUrlEditionReducerCreator } from '../../../src/short-urls/reducers/shortUrlEdition';
EDIT_SHORT_URL_START,
EDIT_SHORT_URL_ERROR,
SHORT_URL_EDITED,
editShortUrl,
ShortUrlEditedAction,
} from '../../../src/short-urls/reducers/shortUrlEdition';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
import { SelectedServer } from '../../../src/servers/data'; import { SelectedServer } from '../../../src/servers/data';
@ -14,48 +8,59 @@ describe('shortUrlEditionReducer', () => {
const longUrl = 'https://shlink.io'; const longUrl = 'https://shlink.io';
const shortCode = 'abc123'; const shortCode = 'abc123';
const shortUrl = Mock.of<ShortUrl>({ longUrl, shortCode }); const shortUrl = Mock.of<ShortUrl>({ longUrl, shortCode });
const updateShortUrl = jest.fn().mockResolvedValue(shortUrl);
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl });
const { reducer, editShortUrl } = shortUrlEditionReducerCreator(buildShlinkApiClient);
afterEach(jest.clearAllMocks);
describe('reducer', () => { describe('reducer', () => {
it('returns loading on EDIT_SHORT_URL_START', () => { it('returns loading on EDIT_SHORT_URL_START', () => {
expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: EDIT_SHORT_URL_START }))).toEqual({ expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: editShortUrl.pending.toString() }))).toEqual({
saving: true, saving: true,
saved: false,
error: false, error: false,
}); });
}); });
it('returns error on EDIT_SHORT_URL_ERROR', () => { it('returns error on EDIT_SHORT_URL_ERROR', () => {
expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: EDIT_SHORT_URL_ERROR }))).toEqual({ expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: editShortUrl.rejected.toString() }))).toEqual({
saving: false, saving: false,
saved: false,
error: true, error: true,
}); });
}); });
it('returns provided tags and shortCode on SHORT_URL_EDITED', () => { it('returns provided tags and shortCode on SHORT_URL_EDITED', () => {
expect(reducer(undefined, { type: SHORT_URL_EDITED, payload: shortUrl })).toEqual({ expect(reducer(undefined, { type: editShortUrl.fulfilled.toString(), payload: shortUrl })).toEqual({
shortUrl, shortUrl,
saving: false, saving: false,
saved: true,
error: false, error: false,
}); });
}); });
}); });
describe('editShortUrl', () => { describe('editShortUrl', () => {
const updateShortUrl = jest.fn().mockResolvedValue(shortUrl);
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl });
const dispatch = jest.fn(); const dispatch = jest.fn();
const createGetState = (selectedServer: SelectedServer = null) => () => Mock.of<ShlinkState>({ selectedServer }); const createGetState = (selectedServer: SelectedServer = null) => () => Mock.of<ShlinkState>({ selectedServer });
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
it.each([[undefined], [null], ['example.com']])('dispatches short URL on success', async (domain) => { it.each([[undefined], [null], ['example.com']])('dispatches short URL on success', async (domain) => {
await editShortUrl(buildShlinkApiClient)({ shortCode, domain, data: { longUrl } })(dispatch, createGetState()); await editShortUrl({ shortCode, domain, data: { longUrl } })(dispatch, createGetState(), {});
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl }); expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, payload: shortUrl }); type: editShortUrl.pending.toString(),
}));
expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: editShortUrl.fulfilled.toString(),
payload: shortUrl,
}));
}); });
it('dispatches error on failure', async () => { it('dispatches error on failure', async () => {
@ -63,18 +68,14 @@ describe('shortUrlEditionReducer', () => {
updateShortUrl.mockRejectedValue(error); updateShortUrl.mockRejectedValue(error);
try { await editShortUrl({ shortCode, data: { longUrl } })(dispatch, createGetState(), {});
await editShortUrl(buildShlinkApiClient)({ shortCode, data: { longUrl } })(dispatch, createGetState());
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { longUrl }); expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: editShortUrl.pending.toString() }));
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_ERROR }); expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: editShortUrl.rejected.toString() }));
}); });
}); });
}); });

View file

@ -181,7 +181,7 @@ describe('shortUrlsListReducer', () => {
error: false, error: false,
}; };
const result = reducer(state, { type: SHORT_URL_EDITED, payload: editedShortUrl } as any); const result = reducer(state, { type: `${SHORT_URL_EDITED}/fulfilled`, payload: editedShortUrl } as any);
expect(result.shortUrls?.data).toEqual(expectedList); expect(result.shortUrls?.data).toEqual(expectedList);
}); });