Proposal: a modern & typed way of writing Redux actions doing API requests (#30270)
This commit is contained in:
parent
3a862439df
commit
10ec421dd4
14 changed files with 281 additions and 125 deletions
|
@ -1,18 +1,9 @@
|
||||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
import { apiSubmitAccountNote } from 'mastodon/api/accounts';
|
||||||
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
import api from '../api';
|
export const submitAccountNote = createDataLoadingThunk(
|
||||||
|
|
||||||
export const submitAccountNote = createAppAsyncThunk(
|
|
||||||
'account_note/submit',
|
'account_note/submit',
|
||||||
async (args: { id: string; value: string }) => {
|
(accountId: string, note: string) => apiSubmitAccountNote(accountId, note),
|
||||||
const response = await api().post<ApiRelationshipJSON>(
|
(relationship) => ({ relationship }),
|
||||||
`/api/v1/accounts/${args.id}/note`,
|
{ skipLoading: true },
|
||||||
{
|
|
||||||
comment: args.value,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { relationship: response.data };
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,10 +3,6 @@ import api, { getLinks } from '../api';
|
||||||
import { fetchRelationships } from './accounts';
|
import { fetchRelationships } from './accounts';
|
||||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
||||||
|
|
||||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
|
||||||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
|
|
||||||
export const REBLOG_FAIL = 'REBLOG_FAIL';
|
|
||||||
|
|
||||||
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
|
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
|
||||||
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
|
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
|
||||||
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
|
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
|
||||||
|
@ -15,10 +11,6 @@ export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
||||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
||||||
|
|
||||||
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
|
|
||||||
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
|
|
||||||
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
|
|
||||||
|
|
||||||
export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
||||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||||
|
@ -51,83 +43,7 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
|
||||||
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
|
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
|
||||||
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
|
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
|
||||||
|
|
||||||
export function reblog(status, visibility) {
|
export * from "./interactions_typed";
|
||||||
return function (dispatch) {
|
|
||||||
dispatch(reblogRequest(status));
|
|
||||||
|
|
||||||
api().post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) {
|
|
||||||
// The reblog API method returns a new status wrapped around the original. In this case we are only
|
|
||||||
// interested in how the original is modified, hence passing it skipping the wrapper
|
|
||||||
dispatch(importFetchedStatus(response.data.reblog));
|
|
||||||
dispatch(reblogSuccess(status));
|
|
||||||
}).catch(function (error) {
|
|
||||||
dispatch(reblogFail(status, error));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unreblog(status) {
|
|
||||||
return (dispatch) => {
|
|
||||||
dispatch(unreblogRequest(status));
|
|
||||||
|
|
||||||
api().post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
|
|
||||||
dispatch(importFetchedStatus(response.data));
|
|
||||||
dispatch(unreblogSuccess(status));
|
|
||||||
}).catch(error => {
|
|
||||||
dispatch(unreblogFail(status, error));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reblogRequest(status) {
|
|
||||||
return {
|
|
||||||
type: REBLOG_REQUEST,
|
|
||||||
status: status,
|
|
||||||
skipLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reblogSuccess(status) {
|
|
||||||
return {
|
|
||||||
type: REBLOG_SUCCESS,
|
|
||||||
status: status,
|
|
||||||
skipLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reblogFail(status, error) {
|
|
||||||
return {
|
|
||||||
type: REBLOG_FAIL,
|
|
||||||
status: status,
|
|
||||||
error: error,
|
|
||||||
skipLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unreblogRequest(status) {
|
|
||||||
return {
|
|
||||||
type: UNREBLOG_REQUEST,
|
|
||||||
status: status,
|
|
||||||
skipLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unreblogSuccess(status) {
|
|
||||||
return {
|
|
||||||
type: UNREBLOG_SUCCESS,
|
|
||||||
status: status,
|
|
||||||
skipLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unreblogFail(status, error) {
|
|
||||||
return {
|
|
||||||
type: UNREBLOG_FAIL,
|
|
||||||
status: status,
|
|
||||||
error: error,
|
|
||||||
skipLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function favourite(status) {
|
export function favourite(status) {
|
||||||
return function (dispatch) {
|
return function (dispatch) {
|
||||||
|
|
30
app/javascript/mastodon/actions/interactions_typed.ts
Normal file
30
app/javascript/mastodon/actions/interactions_typed.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
|
||||||
|
import type { StatusVisibility } from 'mastodon/models/status';
|
||||||
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import { importFetchedStatus } from './importer';
|
||||||
|
|
||||||
|
export const reblog = createDataLoadingThunk(
|
||||||
|
'status/reblog',
|
||||||
|
(statusId: string, visibility: StatusVisibility) =>
|
||||||
|
apiReblog(statusId, visibility),
|
||||||
|
(data, { dispatch, discardLoadData }) => {
|
||||||
|
// The reblog API method returns a new status wrapped around the original. In this case we are only
|
||||||
|
// interested in how the original is modified, hence passing it skipping the wrapper
|
||||||
|
dispatch(importFetchedStatus(data.reblog));
|
||||||
|
|
||||||
|
// The payload is not used in any actions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const unreblog = createDataLoadingThunk(
|
||||||
|
'status/unreblog',
|
||||||
|
(statusId: string) => apiUnreblog(statusId),
|
||||||
|
(data, { dispatch, discardLoadData }) => {
|
||||||
|
dispatch(importFetchedStatus(data));
|
||||||
|
|
||||||
|
// The payload is not used in any actions
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios';
|
import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import LinkHeader from 'http-link-header';
|
import LinkHeader from 'http-link-header';
|
||||||
|
|
||||||
|
@ -58,3 +58,17 @@ export default function api(withAuthorization = true) {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<ApiResponse = unknown>(
|
||||||
|
method: Method,
|
||||||
|
url: string,
|
||||||
|
params?: unknown,
|
||||||
|
) {
|
||||||
|
const { data } = await api().request<ApiResponse>({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
7
app/javascript/mastodon/api/accounts.ts
Normal file
7
app/javascript/mastodon/api/accounts.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiRequest } from 'mastodon/api';
|
||||||
|
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||||
|
|
||||||
|
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||||
|
apiRequest<ApiRelationshipJSON>('post', `/api/v1/accounts/${id}/note`, {
|
||||||
|
comment: value,
|
||||||
|
});
|
10
app/javascript/mastodon/api/interactions.ts
Normal file
10
app/javascript/mastodon/api/interactions.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { apiRequest } from 'mastodon/api';
|
||||||
|
import type { Status, StatusVisibility } from 'mastodon/models/status';
|
||||||
|
|
||||||
|
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||||
|
apiRequest<{ reblog: Status }>('post', `v1/statuses/${statusId}/reblog`, {
|
||||||
|
visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiUnreblog = (statusId: string) =>
|
||||||
|
apiRequest<Status>('post', `v1/statuses/${statusId}/unreblog`);
|
|
@ -96,9 +96,9 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
||||||
|
|
||||||
onModalReblog (status, privacy) {
|
onModalReblog (status, privacy) {
|
||||||
if (status.get('reblogged')) {
|
if (status.get('reblogged')) {
|
||||||
dispatch(unreblog(status));
|
dispatch(unreblog(status.id));
|
||||||
} else {
|
} else {
|
||||||
dispatch(reblog(status, privacy));
|
dispatch(reblog(status.id, privacy));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ const mapStateToProps = (state, { account }) => ({
|
||||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||||
|
|
||||||
onSave (value) {
|
onSave (value) {
|
||||||
dispatch(submitAccountNote({ id: account.get('id'), value}));
|
dispatch(submitAccountNote(account.get('id'), value));
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -39,12 +39,12 @@ const mapDispatchToProps = dispatch => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onModalReblog (status, privacy) {
|
onModalReblog (status, privacy) {
|
||||||
dispatch(reblog(status, privacy));
|
dispatch(reblog(status.id, privacy));
|
||||||
},
|
},
|
||||||
|
|
||||||
onReblog (status, e) {
|
onReblog (status, e) {
|
||||||
if (status.get('reblogged')) {
|
if (status.get('reblogged')) {
|
||||||
dispatch(unreblog(status));
|
dispatch(unreblog(status.id));
|
||||||
} else {
|
} else {
|
||||||
if (e.shiftKey || !boostModal) {
|
if (e.shiftKey || !boostModal) {
|
||||||
this.onModalReblog(status);
|
this.onModalReblog(status);
|
||||||
|
|
|
@ -123,7 +123,7 @@ class Footer extends ImmutablePureComponent {
|
||||||
|
|
||||||
_performReblog = (status, privacy) => {
|
_performReblog = (status, privacy) => {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
dispatch(reblog(status, privacy));
|
dispatch(reblog(status.id, privacy));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReblogClick = e => {
|
handleReblogClick = e => {
|
||||||
|
@ -132,7 +132,7 @@ class Footer extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
if (status.get('reblogged')) {
|
if (status.get('reblogged')) {
|
||||||
dispatch(unreblog(status));
|
dispatch(unreblog(status.id));
|
||||||
} else if ((e && e.shiftKey) || !boostModal) {
|
} else if ((e && e.shiftKey) || !boostModal) {
|
||||||
this._performReblog(status);
|
this._performReblog(status);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -74,12 +74,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onModalReblog (status, privacy) {
|
onModalReblog (status, privacy) {
|
||||||
dispatch(reblog(status, privacy));
|
dispatch(reblog(status.id, privacy));
|
||||||
},
|
},
|
||||||
|
|
||||||
onReblog (status, e) {
|
onReblog (status, e) {
|
||||||
if (status.get('reblogged')) {
|
if (status.get('reblogged')) {
|
||||||
dispatch(unreblog(status));
|
dispatch(unreblog(status.id));
|
||||||
} else {
|
} else {
|
||||||
if (e.shiftKey || !boostModal) {
|
if (e.shiftKey || !boostModal) {
|
||||||
this.onModalReblog(status);
|
this.onModalReblog(status);
|
||||||
|
|
|
@ -299,7 +299,7 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleModalReblog = (status, privacy) => {
|
handleModalReblog = (status, privacy) => {
|
||||||
this.props.dispatch(reblog(status, privacy));
|
this.props.dispatch(reblog(status.id, privacy));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReblogClick = (status, e) => {
|
handleReblogClick = (status, e) => {
|
||||||
|
@ -308,7 +308,7 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
if (status.get('reblogged')) {
|
if (status.get('reblogged')) {
|
||||||
dispatch(unreblog(status));
|
dispatch(unreblog(status.id));
|
||||||
} else {
|
} else {
|
||||||
if ((e && e.shiftKey) || !boostModal) {
|
if ((e && e.shiftKey) || !boostModal) {
|
||||||
this.handleModalReblog(status);
|
this.handleModalReblog(status);
|
||||||
|
|
|
@ -3,10 +3,6 @@ import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||||
import { normalizeStatusTranslation } from '../actions/importer/normalizer';
|
import { normalizeStatusTranslation } from '../actions/importer/normalizer';
|
||||||
import {
|
import {
|
||||||
REBLOG_REQUEST,
|
|
||||||
REBLOG_FAIL,
|
|
||||||
UNREBLOG_REQUEST,
|
|
||||||
UNREBLOG_FAIL,
|
|
||||||
FAVOURITE_REQUEST,
|
FAVOURITE_REQUEST,
|
||||||
FAVOURITE_FAIL,
|
FAVOURITE_FAIL,
|
||||||
UNFAVOURITE_REQUEST,
|
UNFAVOURITE_REQUEST,
|
||||||
|
@ -16,6 +12,10 @@ import {
|
||||||
UNBOOKMARK_REQUEST,
|
UNBOOKMARK_REQUEST,
|
||||||
UNBOOKMARK_FAIL,
|
UNBOOKMARK_FAIL,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
|
import {
|
||||||
|
reblog,
|
||||||
|
unreblog,
|
||||||
|
} from '../actions/interactions_typed';
|
||||||
import {
|
import {
|
||||||
STATUS_MUTE_SUCCESS,
|
STATUS_MUTE_SUCCESS,
|
||||||
STATUS_UNMUTE_SUCCESS,
|
STATUS_UNMUTE_SUCCESS,
|
||||||
|
@ -65,6 +65,7 @@ const statusTranslateUndo = (state, id) => {
|
||||||
|
|
||||||
const initialState = ImmutableMap();
|
const initialState = ImmutableMap();
|
||||||
|
|
||||||
|
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||||
export default function statuses(state = initialState, action) {
|
export default function statuses(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STATUS_FETCH_REQUEST:
|
case STATUS_FETCH_REQUEST:
|
||||||
|
@ -91,14 +92,6 @@ export default function statuses(state = initialState, action) {
|
||||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
||||||
case UNBOOKMARK_FAIL:
|
case UNBOOKMARK_FAIL:
|
||||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
|
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
|
||||||
case REBLOG_REQUEST:
|
|
||||||
return state.setIn([action.status.get('id'), 'reblogged'], true);
|
|
||||||
case REBLOG_FAIL:
|
|
||||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false);
|
|
||||||
case UNREBLOG_REQUEST:
|
|
||||||
return state.setIn([action.status.get('id'), 'reblogged'], false);
|
|
||||||
case UNREBLOG_FAIL:
|
|
||||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], true);
|
|
||||||
case STATUS_MUTE_SUCCESS:
|
case STATUS_MUTE_SUCCESS:
|
||||||
return state.setIn([action.id, 'muted'], true);
|
return state.setIn([action.id, 'muted'], true);
|
||||||
case STATUS_UNMUTE_SUCCESS:
|
case STATUS_UNMUTE_SUCCESS:
|
||||||
|
@ -128,6 +121,15 @@ export default function statuses(state = initialState, action) {
|
||||||
case STATUS_TRANSLATE_UNDO:
|
case STATUS_TRANSLATE_UNDO:
|
||||||
return statusTranslateUndo(state, action.id);
|
return statusTranslateUndo(state, action.id);
|
||||||
default:
|
default:
|
||||||
|
if(reblog.pending.match(action))
|
||||||
|
return state.setIn([action.meta.params.statusId, 'reblogged'], true);
|
||||||
|
else if(reblog.rejected.match(action))
|
||||||
|
return state.get(action.meta.params.statusId) === undefined ? state : state.setIn([action.meta.params.statusId, 'reblogged'], false);
|
||||||
|
else if(unreblog.pending.match(action))
|
||||||
|
return state.setIn([action.meta.params.statusId, 'reblogged'], false);
|
||||||
|
else if(unreblog.rejected.match(action))
|
||||||
|
return state.get(action.meta.params.statusId) === undefined ? state : state.setIn([action.meta.params.statusId, 'reblogged'], true);
|
||||||
|
else
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import type { BaseThunkAPI } from '@reduxjs/toolkit/dist/createAsyncThunk';
|
||||||
|
|
||||||
import type { AppDispatch, RootState } from './store';
|
import type { AppDispatch, RootState } from './store';
|
||||||
|
|
||||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||||
|
@ -13,8 +15,192 @@ export interface AsyncThunkRejectValue {
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppMeta {
|
||||||
|
skipLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||||
state: RootState;
|
state: RootState;
|
||||||
dispatch: AppDispatch;
|
dispatch: AppDispatch;
|
||||||
rejectValue: AsyncThunkRejectValue;
|
rejectValue: AsyncThunkRejectValue;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
type AppThunkApi = Pick<
|
||||||
|
BaseThunkAPI<
|
||||||
|
RootState,
|
||||||
|
unknown,
|
||||||
|
AppDispatch,
|
||||||
|
AsyncThunkRejectValue,
|
||||||
|
AppMeta,
|
||||||
|
AppMeta
|
||||||
|
>,
|
||||||
|
'getState' | 'dispatch'
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface AppThunkOptions {
|
||||||
|
skipLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBaseAsyncThunk = createAsyncThunk.withTypes<{
|
||||||
|
state: RootState;
|
||||||
|
dispatch: AppDispatch;
|
||||||
|
rejectValue: AsyncThunkRejectValue;
|
||||||
|
fulfilledMeta: AppMeta;
|
||||||
|
rejectedMeta: AppMeta;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
export function createThunk<Arg = void, Returned = void>(
|
||||||
|
name: string,
|
||||||
|
creator: (arg: Arg, api: AppThunkApi) => Returned | Promise<Returned>,
|
||||||
|
options: AppThunkOptions = {},
|
||||||
|
) {
|
||||||
|
return createBaseAsyncThunk(
|
||||||
|
name,
|
||||||
|
async (
|
||||||
|
arg: Arg,
|
||||||
|
{ getState, dispatch, fulfillWithValue, rejectWithValue },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const result = await creator(arg, { dispatch, getState });
|
||||||
|
|
||||||
|
return fulfillWithValue(result, {
|
||||||
|
skipLoading: options.skipLoading,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue({ error }, { skipLoading: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getPendingMeta() {
|
||||||
|
if (options.skipLoading) return { skipLoading: true };
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const discardLoadDataInPayload = Symbol('discardLoadDataInPayload');
|
||||||
|
type DiscardLoadData = typeof discardLoadDataInPayload;
|
||||||
|
|
||||||
|
type OnData<LoadDataResult, ReturnedData> = (
|
||||||
|
data: LoadDataResult,
|
||||||
|
api: AppThunkApi & {
|
||||||
|
discardLoadData: DiscardLoadData;
|
||||||
|
},
|
||||||
|
) => ReturnedData | DiscardLoadData | Promise<ReturnedData | DiscardLoadData>;
|
||||||
|
|
||||||
|
// Overload when there is no `onData` method, the payload is the `onData` result
|
||||||
|
export function createDataLoadingThunk<
|
||||||
|
LoadDataResult,
|
||||||
|
Args extends readonly unknown[],
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||||
|
thunkOptions?: AppThunkOptions,
|
||||||
|
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
||||||
|
|
||||||
|
// Overload when the `onData` method returns discardLoadDataInPayload, then the payload is empty
|
||||||
|
export function createDataLoadingThunk<
|
||||||
|
LoadDataResult,
|
||||||
|
Args extends readonly unknown[],
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||||
|
onDataOrThunkOptions?:
|
||||||
|
| AppThunkOptions
|
||||||
|
| OnData<LoadDataResult, DiscardLoadData>,
|
||||||
|
thunkOptions?: AppThunkOptions,
|
||||||
|
): ReturnType<typeof createThunk<Args, void>>;
|
||||||
|
|
||||||
|
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
|
||||||
|
export function createDataLoadingThunk<
|
||||||
|
LoadDataResult,
|
||||||
|
Args extends readonly unknown[],
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||||
|
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, void>,
|
||||||
|
thunkOptions?: AppThunkOptions,
|
||||||
|
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
||||||
|
|
||||||
|
// Overload when there is an `onData` method returning something
|
||||||
|
export function createDataLoadingThunk<
|
||||||
|
LoadDataResult,
|
||||||
|
Args extends readonly unknown[],
|
||||||
|
Returned,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||||
|
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, Returned>,
|
||||||
|
thunkOptions?: AppThunkOptions,
|
||||||
|
): ReturnType<typeof createThunk<Args, Returned>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function creates a Redux Thunk that handles loading data asynchronously (usually from the API), dispatching `pending`, `fullfilled` and `rejected` actions.
|
||||||
|
*
|
||||||
|
* You can run a callback on the `onData` results to either dispatch side effects or modify the payload.
|
||||||
|
*
|
||||||
|
* It is a wrapper around RTK's [`createAsyncThunk`](https://redux-toolkit.js.org/api/createAsyncThunk)
|
||||||
|
* @param name Prefix for the actions types
|
||||||
|
* @param loadData Function that loads the data. It's arguments will become the thunk's arguments
|
||||||
|
* @param onDataOrThunkOptions
|
||||||
|
* Callback called on the results from `loadData`.
|
||||||
|
*
|
||||||
|
* First argument will be the return from `loadData`.
|
||||||
|
*
|
||||||
|
* Second argument is an object with: `dispatch`, `getState` and `discardLoadData`.
|
||||||
|
* It can return:
|
||||||
|
* - `undefined` (or no explicit return), meaning that the `onData` results will be the payload
|
||||||
|
* - `discardLoadData` to discard the `onData` results and return an empty payload
|
||||||
|
* - anything else, which will be the payload
|
||||||
|
*
|
||||||
|
* You can also omit this parameter and pass `thunkOptions` directly
|
||||||
|
* @param maybeThunkOptions
|
||||||
|
* Additional Mastodon specific options for the thunk. Currently supports:
|
||||||
|
* - `skipLoading` to avoid showing the loading bar when the request is in progress
|
||||||
|
* @returns The created thunk
|
||||||
|
*/
|
||||||
|
export function createDataLoadingThunk<
|
||||||
|
LoadDataResult,
|
||||||
|
Args extends readonly unknown[],
|
||||||
|
Returned,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||||
|
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, Returned>,
|
||||||
|
maybeThunkOptions?: AppThunkOptions,
|
||||||
|
) {
|
||||||
|
let onData: OnData<LoadDataResult, Returned> | undefined;
|
||||||
|
let thunkOptions: AppThunkOptions | undefined;
|
||||||
|
|
||||||
|
if (typeof onDataOrThunkOptions === 'function') onData = onDataOrThunkOptions;
|
||||||
|
else if (typeof onDataOrThunkOptions === 'object')
|
||||||
|
thunkOptions = onDataOrThunkOptions;
|
||||||
|
|
||||||
|
if (maybeThunkOptions) {
|
||||||
|
thunkOptions = maybeThunkOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createThunk<Args, Returned>(
|
||||||
|
name,
|
||||||
|
async (arg, { getState, dispatch }) => {
|
||||||
|
const data = await loadData(...arg);
|
||||||
|
|
||||||
|
if (!onData) return data as Returned;
|
||||||
|
|
||||||
|
const result = await onData(data, {
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
discardLoadData: discardLoadDataInPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if there is no return in `onData`, we return the `onData` result
|
||||||
|
if (typeof result === 'undefined') return data as Returned;
|
||||||
|
// the user explicitely asked to discard the payload
|
||||||
|
else if (result === discardLoadDataInPayload)
|
||||||
|
return undefined as Returned;
|
||||||
|
else return result;
|
||||||
|
},
|
||||||
|
thunkOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue