Proposal: a modern & typed way of writing Redux actions doing API requests (#30270)

main
Renaud Chaput 2024-05-23 11:50:13 +02:00 committed by GitHub
parent 3a862439df
commit 10ec421dd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 281 additions and 125 deletions

View File

@ -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 };
},
); );

View File

@ -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) {

View 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;
},
);

View File

@ -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;
}

View 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,
});

View 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`);

View File

@ -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));
} }
}, },

View File

@ -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));
}, },
}); });

View File

@ -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);

View File

@ -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 {

View File

@ -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);

View File

@ -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);

View File

@ -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:
return state; 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;
} }
} }

View File

@ -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,
);
}