mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Migrated tagsList reducer to RTK
This commit is contained in:
parent
b7622b2b38
commit
f9bfb742da
4 changed files with 96 additions and 99 deletions
|
@ -8,7 +8,6 @@ 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';
|
||||
|
@ -28,7 +27,7 @@ export default (container: IContainer) => combineReducers<ShlinkState>({
|
|||
domainVisits: domainVisitsReducer,
|
||||
orphanVisits: orphanVisitsReducer,
|
||||
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||
tagsList: tagsListReducer,
|
||||
tagsList: container.tagsListReducer,
|
||||
tagDelete: container.tagDeleteReducer,
|
||||
tagEdit: container.tagEditReducer,
|
||||
mercureInfo: container.mercureInfoReducer,
|
||||
|
|
|
@ -1,24 +1,19 @@
|
|||
import { createAction, PayloadAction } from '@reduxjs/toolkit';
|
||||
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 { CREATE_SHORT_URL } 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 LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
|
||||
const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
|
||||
|
||||
type TagsStatsMap = Record<string, TagStats>;
|
||||
|
||||
|
@ -31,22 +26,12 @@ export interface TagsList {
|
|||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface ListTagsAction extends Action<string> {
|
||||
interface ListTags {
|
||||
tags: string[];
|
||||
stats: TagsStatsMap;
|
||||
}
|
||||
|
||||
type FilterTagsAction = PayloadAction<string>;
|
||||
|
||||
type TagsCombinedAction = ListTagsAction
|
||||
& DeleteTagAction
|
||||
& CreateVisitsAction
|
||||
& CreateShortUrlAction
|
||||
& EditTagAction
|
||||
& FilterTagsAction
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState = {
|
||||
const initialState: TagsList = {
|
||||
tags: [],
|
||||
filteredTags: [],
|
||||
stats: {},
|
||||
|
@ -80,47 +65,15 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
|||
}, {}),
|
||||
);
|
||||
|
||||
export default buildReducer<TagsList, TagsCombinedAction>({
|
||||
[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, { payload: 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(
|
||||
LIST_TAGS,
|
||||
async (_: void, { getState }): Promise<ListTags> => {
|
||||
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<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
||||
|
@ -129,10 +82,49 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
|||
return acc;
|
||||
}, {});
|
||||
|
||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
return { tags, stats: processedStats };
|
||||
},
|
||||
);
|
||||
|
||||
export const filterTags = createAction<string>(FILTER_TAGS);
|
||||
|
||||
export const reducer = (listTagsThunk: ReturnType<typeof listTags>) => createSlice({
|
||||
name: 'shlink/tagsList',
|
||||
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),
|
||||
}));
|
||||
|
||||
// TODO Do not hardcode action type here. Inject async thunk instead
|
||||
builder.addCase(`${CREATE_SHORT_URL}/fulfilled`, ({ tags: stateTags, ...rest }, { payload }: any) => ({
|
||||
...rest,
|
||||
tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
||||
}));
|
||||
},
|
||||
}).reducer;
|
||||
|
|
|
@ -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, reducer } from '../reducers/tagsList';
|
||||
import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete';
|
||||
import { tagEdited, tagEditReducerCreator } from '../reducers/tagEdit';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
|
@ -44,6 +44,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator');
|
||||
|
||||
bottle.serviceFactory('tagsListReducer', reducer, 'listTags');
|
||||
|
||||
// Actions
|
||||
const listTagsActionFactory = (force: boolean) =>
|
||||
({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force);
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
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,
|
||||
reducer as reducerCreator,
|
||||
} from '../../../src/tags/reducers/tagsList';
|
||||
import { ShlinkState } from '../../../src/container/types';
|
||||
import { ShortUrl } from '../../../src/short-urls/data';
|
||||
|
@ -16,17 +13,22 @@ import { tagDeleted } from '../../../src/tags/reducers/tagDelete';
|
|||
|
||||
describe('tagsListReducer', () => {
|
||||
const state = (props: Partial<TagsList>) => Mock.of<TagsList>(props);
|
||||
const buildShlinkApiClient = jest.fn();
|
||||
const listTags = listTagsCreator(buildShlinkApiClient, true);
|
||||
const reducer = reducerCreator(listTags);
|
||||
|
||||
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 +37,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 +55,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 +73,7 @@ describe('tagsListReducer', () => {
|
|||
{
|
||||
type: tagEdited.toString(),
|
||||
payload: { oldName, newName },
|
||||
} as any,
|
||||
},
|
||||
)).toEqual({
|
||||
tags: expectedTags,
|
||||
filteredTags: expectedTags,
|
||||
|
@ -80,7 +85,7 @@ describe('tagsListReducer', () => {
|
|||
const payload = 'Fo';
|
||||
const filteredTags = ['foo', 'Foo2', 'fo'];
|
||||
|
||||
expect(reducer(state({ tags }), { type: FILTER_TAGS, payload } as any)).toEqual({
|
||||
expect(reducer(state({ tags }), { type: filterTags.toString(), payload })).toEqual({
|
||||
tags,
|
||||
filteredTags,
|
||||
});
|
||||
|
@ -94,31 +99,28 @@ describe('tagsListReducer', () => {
|
|||
const tags = ['foo', 'bar', 'baz', 'foo2', 'fo'];
|
||||
const payload = Mock.of<ShortUrl>({ tags: shortUrlTags });
|
||||
|
||||
expect(reducer(state({ tags }), { type: `${CREATE_SHORT_URL}/fulfilled`, payload } as any)).toEqual({
|
||||
expect(reducer(state({ tags }), { type: `${CREATE_SHORT_URL}/fulfilled`, payload })).toEqual({
|
||||
tags: expectedTags,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterTags', () => {
|
||||
it('creates expected action', () => expect(filterTags('foo')).toEqual({ type: FILTER_TAGS, payload: '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<ShlinkState>());
|
||||
const buildShlinkApiClient = jest.fn();
|
||||
const listTagsMock = jest.fn();
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
const assertNoAction = async (tagsList: TagsList) => {
|
||||
getState.mockReturnValue(Mock.of<ShlinkState>({ 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 +136,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 +173,6 @@ describe('tagsListReducer', () => {
|
|||
});
|
||||
|
||||
await assertErrorResult();
|
||||
|
||||
expect(listTagsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue