Use default action type and add status to async actions

Since TypeScript can infer types based on string types, it has became
easier to define a single constant of an action that returns a promise,
and then dispatch actions with different statuses: pending, resolved,
and/or rejected. This seems like more typing at first, but in the long
run it will become easier to write generic reducer methods, and the
names of reduced types will not have to be repeated every time.

For example, previously we had to write:

    type MyActionTypes =
      IAsyncAction<IUser, 'LOGIN_PENDING', 'LOGIN_RESOLVED', 'LOGIN_REJECTED'>
      | ...

And now we can write:

    type MyActionTypes =
      IAsyncAction<IUser, 'LOGIN'>
      | ...
This commit is contained in:
Jerko Steiner 2019-04-01 15:20:41 +08:00
parent 61e7e8db4f
commit 8b6f90235e
18 changed files with 253 additions and 277 deletions

View File

@ -1,6 +1,6 @@
import {IAction} from './IAction'
export type GetAction<MyTypes, T extends string> =
MyTypes extends IAction<T>
MyTypes extends IAction<infer U, T>
? MyTypes
: never

View File

@ -0,0 +1,6 @@
import {IAsyncAction} from './IAsyncAction'
export type GetPendingAction<MyTypes, T extends string> =
MyTypes extends IAsyncAction<infer U, T> & {status: 'pending'}
? MyTypes
: never

View File

@ -0,0 +1,6 @@
import {IAsyncAction} from './IAsyncAction'
export type GetResolvedAction<MyTypes, T extends string> =
MyTypes extends IAsyncAction<infer U, T> & {status: 'resolved'}
? MyTypes
: never

View File

@ -1,3 +1,4 @@
export interface IAction<ActionType extends string> {
export interface IAction<T, ActionType extends string> {
payload: T
type: ActionType
}

View File

@ -2,11 +2,9 @@ import {IPendingAction} from './IPendingAction'
import {IResolvedAction} from './IResolvedAction'
import {IRejectedAction} from './IRejectedAction'
export type IAsyncAction<T,
PendingActionType extends string,
ResolvedActionType extends string,
RejectedActionType extends string
> =
IPendingAction<T, PendingActionType>
| IResolvedAction<T, ResolvedActionType>
| IRejectedAction<RejectedActionType>
export type IAsyncStatus = 'pending' | 'resolved' | 'rejected'
export type IAsyncAction<T, ActionType extends string> =
IPendingAction<T, ActionType>
| IResolvedAction<T, ActionType>
| IRejectedAction<ActionType>

View File

@ -1,6 +1,6 @@
import {IAction} from './IAction'
export interface IPendingAction<T, ActionType extends string> extends
IAction<ActionType> {
payload: Promise<T>
IAction<Promise<T>, ActionType> {
status: 'pending'
}

View File

@ -1,6 +1,6 @@
import {IAction} from './IAction'
export interface IRejectedAction<ActionType extends string> extends
IAction<ActionType> {
error: Error
IAction<Error, ActionType> {
status: 'rejected'
}

View File

@ -1,6 +1,7 @@
import {IAction} from './IAction'
export interface IResolvedAction<T, ActionType extends string> extends
IAction<ActionType> {
IAction<T, ActionType> {
payload: T
status: 'resolved'
}

View File

@ -0,0 +1,9 @@
import {IPendingAction} from './IPendingAction'
export class PendingAction<T, ActionType extends string> {
readonly status = 'pending'
constructor(
readonly payload: T,
readonly type: ActionType,
) {}
}

View File

@ -1,6 +1,9 @@
export * from './GetAction'
export * from './GetResolvedAction'
export * from './GetPendingAction'
export * from './IAction'
export * from './IAsyncAction'
export * from './IPendingAction'
export * from './IRejectedAction'
export * from './IResolvedAction'
export * from './PendingAction'

View File

@ -48,37 +48,31 @@ export class CRUDReducer<T extends ICRUDIdable> {
readonly resolvedExtension = '_RESOLVED',
readonly rejectedExtension = '_REJECTED',
) {
const defaultMethodStatus = this.getDefaultMethodStatus()
this.defaultState = {
ids: [],
byId: {},
status: {
post: {
error: '',
isLoading: false,
},
put: {
error: '',
isLoading: false,
},
delete: {
error: '',
isLoading: false,
},
get: {
error: '',
isLoading: false,
},
getMany: {
error: '',
isLoading: false,
},
post: defaultMethodStatus,
put: defaultMethodStatus,
delete: defaultMethodStatus,
get: defaultMethodStatus,
getMany: defaultMethodStatus,
},
}
this.actionTypes = this.getActionTypes()
}
getDefaultMethodStatus(): ICRUDMethodStatus {
return {
error: '',
isLoading: false,
}
}
protected getPromiseActionNames(type: string) {
return {
pending: type + this.pendingExtension,

View File

@ -1,4 +1,4 @@
import {GetAction, IResolvedAction} from '../actions'
import {GetAction, IAction} from '../actions'
export interface ICrumbLink {
name: string
@ -11,7 +11,7 @@ export interface ICrumbs {
}
export type CrumbsActionType =
IResolvedAction<ICrumbs, 'BREADCRUMBS_SET'>
IAction<ICrumbs, 'BREADCRUMBS_SET'>
type Action<T extends string> = GetAction<CrumbsActionType, T>

View File

@ -1,68 +1,43 @@
import {GetAction, IAsyncAction, IResolvedAction} from '../actions'
import {GetAction, IAsyncAction, IAction, PendingAction} from '../actions'
import {IAPIDef, ICredentials, INewUser, IUser} from '@rondo/common'
import {IHTTPClient} from '../http/IHTTPClient'
export enum LoginActionKeys {
LOGIN = 'LOGIN',
LOGIN_PENDING = 'LOGIN_PENDING',
LOGIN_REJECTED = 'LOGIN_REJECTED',
LOGIN_LOG_OUT = 'LOGIN_LOG_OUT',
LOGIN_LOG_OUT_PENDING = 'LOGIN_LOG_OUT_PENDING',
LOGIN_LOG_OUT_REJECTED = 'LOGIN_LOG_OUT_REJECTED',
LOGIN_REGISTER = 'LOGIN_REGISTER',
LOGIN_REGISTER_PENDING = 'LOGIN_REGISTER_PENDING',
LOGIN_REGISTER_REJECTED = 'LOGIN_REGISTER_REJECTED',
LOGIN_REDIRECT_SET = 'LOGIN_REDIRECT_SET',
}
export type LoginActionType =
IAsyncAction<IUser,
'LOGIN_PENDING',
'LOGIN_RESOLVED',
'LOGIN_REJECTED'>
| IAsyncAction<unknown,
'LOGIN_LOGOUT_PENDING',
'LOGIN_LOGOUT_RESOLVED',
'LOGIN_LOGOUT_REJECTED'>
| IAsyncAction<IUser,
'LOGIN_REGISTER_PENDING',
'LOGIN_REGISTER_RESOLVED',
'LOGIN_REGISTER_REJECTED'>
| IResolvedAction<{redirectTo: string}, 'LOGIN_REDIRECT_SET'>
IAsyncAction<IUser, 'LOGIN'>
| IAsyncAction<unknown, 'LOGIN_LOGOUT'>
| IAsyncAction<IUser, 'LOGIN_REGISTER'>
| IAction<{redirectTo: string}, 'LOGIN_REDIRECT_SET'>
type Action<T extends string> = GetAction<LoginActionType, T>
export class LoginActions {
constructor(protected readonly http: IHTTPClient<IAPIDef>) {}
logIn = (credentials: ICredentials): Action<'LOGIN_PENDING'> => {
return {
payload: this.http.post('/auth/login', credentials),
type: 'LOGIN_PENDING',
}
logIn = (credentials: ICredentials) => {
return new PendingAction(
this.http.post('/auth/login', credentials),
'LOGIN',
)
}
logOut = (): Action<'LOGIN_LOGOUT_PENDING'> => {
return {
payload: this.http.get('/auth/logout'),
type: 'LOGIN_LOGOUT_PENDING',
}
logOut = () => {
return new PendingAction(
this.http.get('/auth/logout'),
'LOGIN_LOGOUT',
)
}
register = (profile: INewUser): Action<'LOGIN_REGISTER_PENDING'> => {
return {
payload: this.http.post('/auth/register', profile),
type: 'LOGIN_REGISTER_PENDING',
}
register = (profile: INewUser) => {
return new PendingAction(
this.http.post('/auth/register', profile),
'LOGIN_REGISTER',
)
}
setRedirectTo = (redirectTo: string): Action<'LOGIN_REDIRECT_SET'> => {
return {
payload: {redirectTo},
type: LoginActionKeys.LOGIN_REDIRECT_SET,
type: 'LOGIN_REDIRECT_SET',
}
}
}

View File

@ -2,13 +2,15 @@ import {IUser} from '@rondo/common'
import {LoginActionType} from './LoginActions'
export interface ILoginState {
readonly error?: string
readonly error: string
readonly isLoading: boolean
readonly user?: IUser
readonly redirectTo: string
}
const defaultState: ILoginState = {
error: undefined,
error: '',
isLoading: false,
user: undefined,
redirectTo: '/',
}
@ -18,19 +20,33 @@ export function Login(
action: LoginActionType,
): ILoginState {
switch (action.type) {
case 'LOGIN_RESOLVED':
return {...state, user: action.payload, error: ''}
case 'LOGIN_LOGOUT_RESOLVED':
return {...state, user: undefined}
case 'LOGIN_REJECTED':
return {...state, error: action.error.message}
case 'LOGIN_REGISTER_RESOLVED':
return {...state, user: action.payload, error: ''}
case 'LOGIN_REGISTER_REJECTED':
return {...state, error: action.error.message}
// sync actions
case 'LOGIN_REDIRECT_SET':
return {...state, redirectTo: action.payload.redirectTo}
default:
// async actions
switch (action.status) {
case 'pending':
return {
...state,
isLoading: true,
}
case 'rejected':
return {
...state,
isLoading: false,
error: action.payload.message,
}
case 'resolved':
switch (action.type) {
case 'LOGIN':
return {...state, user: action.payload, error: ''}
case 'LOGIN_LOGOUT':
return {...state, user: undefined}
case 'LOGIN_REGISTER':
return {...state, user: action.payload, error: ''}
}
}
return state
}
}

View File

@ -4,13 +4,6 @@ import {getError} from '../test-utils'
describe('PromiseMiddleware', () => {
describe('constructor', () => {
it('throws an error when action types are the same', () => {
expect(() => new PromiseMiddleware('a', 'a', 'a')).toThrowError()
expect(new PromiseMiddleware('a', 'b', 'c')).toBeTruthy()
})
})
let store!: Store
beforeEach(() => {
const middleware = new PromiseMiddleware()
@ -22,8 +15,11 @@ describe('PromiseMiddleware', () => {
it('does nothing when payload is not a promise', () => {
const action = {type: 'test'}
store.dispatch(action)
expect(store.getState().slice(1)).toEqual([action])
const result = store.dispatch(action)
expect(result).toBe(action)
expect(store.getState().slice(1)).toEqual([{
type: action.type,
}])
})
it('dispatches pending and resolved action', async () => {
@ -31,16 +27,21 @@ describe('PromiseMiddleware', () => {
const type = 'TEST'
const action = {
payload: Promise.resolve(value),
type: `${type}_PENDING`,
type,
}
const result = store.dispatch(action)
expect(result).toBe(action)
expect(result).toEqual({
...action,
status: 'pending',
})
await result.payload
expect(store.getState().slice(1)).toEqual([{
...action,
status: 'pending',
}, {
payload: value,
type: type + '_RESOLVED',
status: 'resolved',
type,
}])
})
@ -49,17 +50,23 @@ describe('PromiseMiddleware', () => {
const type = 'TEST'
const action = {
payload: Promise.reject(error),
type: `${type}_PENDING`,
type,
}
const result = store.dispatch(action)
expect(result).toBe(action)
expect(result).toEqual({
...action,
status: 'pending',
})
const err = await getError(result.payload)
expect(err).toBe(error)
expect(store.getState().slice(1)).toEqual([{
...action,
payload: action.payload,
status: 'pending',
type,
}, {
error,
type: `${type}_REJECTED`,
status: 'rejected',
type,
}])
})

View File

@ -20,45 +20,34 @@ function isPromise(value: any): value is Promise<any> {
* const middleware = applyMiddleware(new PromiseMiddleware().handle)
*/
export class PromiseMiddleware {
protected regexp: RegExp
constructor(
readonly pendingExtension = '_PENDING',
readonly resolvedExtension = '_RESOLVED',
readonly rejectedExtension = '_REJECTED',
) {
assert(
this.pendingExtension !== this.resolvedExtension &&
this.resolvedExtension !== this.rejectedExtension &&
this.pendingExtension !== this.rejectedExtension,
'Pending, resolved and rejected extensions must be unique')
this.regexp = new RegExp(pendingExtension + '$')
}
handle: Middleware = store => next => (action: AnyAction) => {
const {payload, type} = action
// Propagate this action. Only attach listeners to the promise.
next(action)
if (!isPromise(payload)) {
return
return next(action)
}
const strippedType = type.replace(this.regexp, '')
const pendingAction = {
...action,
status: 'pending',
}
// Propagate this action. Only attach listeners to the promise.
next(pendingAction)
payload
.then(result => {
store.dispatch({
payload: result,
type: strippedType + this.resolvedExtension,
status: 'resolved',
type,
})
})
.catch(err => {
store.dispatch({
error: err,
type: strippedType + this.rejectedExtension,
status: 'rejected',
type,
})
})
return action
return pendingAction
}
}

View File

@ -1,74 +1,44 @@
import {IAPIDef} from '@rondo/common'
import {GetAction, IAsyncAction} from '../actions'
import {GetPendingAction, IAsyncAction, PendingAction} from '../actions'
import {IHTTPClient} from '../http/IHTTPClient'
import {ITeam, IUser, IUserInTeam} from '@rondo/common'
export type TeamActionType =
IAsyncAction<ITeam[],
'TEAMS_PENDING',
'TEAMS_RESOLVED',
'TEAMS_REJECTED'>
| IAsyncAction<ITeam,
'TEAM_CREATE_PENDING',
'TEAM_CREATE_RESOLVED',
'TEAM_CREATE_REJECTED'>
| IAsyncAction<ITeam,
'TEAM_UPDATE_PENDING',
'TEAM_UPDATE_RESOLVED',
'TEAM_UPDATE_REJECTED'>
| IAsyncAction<{id: number},
'TEAM_REMOVE_PENDING',
'TEAM_REMOVE_RESOLVED',
'TEAM_REMOVE_REJECTED'>
| IAsyncAction<IUserInTeam,
'TEAM_USER_ADD_PENDING',
'TEAM_USER_ADD_RESOLVED',
'TEAM_USER_ADD_REJECTED'>
| IAsyncAction<{userId: number, teamId: number},
'TEAM_USER_REMOVE_PENDING',
'TEAM_USER_REMOVE_RESOLVED',
'TEAM_USER_REMOVE_REJECTED'>
| IAsyncAction<{teamId: number, usersInTeam: IUserInTeam[]},
'TEAM_USERS_PENDING',
'TEAM_USERS_RESOLVED',
'TEAM_USERS_REJECTED'>
| IAsyncAction<IUser | undefined,
'TEAM_USER_FIND_PENDING',
'TEAM_USER_FIND_RESOLVED',
'TEAM_USER_FIND_REJECTED'>
IAsyncAction<ITeam[], 'TEAMS'>
| IAsyncAction<ITeam, 'TEAM_CREATE'>
| IAsyncAction<ITeam, 'TEAM_UPDATE'>
| IAsyncAction<{id: number}, 'TEAM_REMOVE'>
| IAsyncAction<IUserInTeam, 'TEAM_USER_ADD'>
| IAsyncAction<{userId: number, teamId: number}, 'TEAM_USER_REMOVE'>
| IAsyncAction<{teamId: number, usersInTeam: IUserInTeam[]}, 'TEAM_USERS'>
| IAsyncAction<IUser | undefined, 'TEAM_USER_FIND'>
type Action<T extends string> = GetAction<TeamActionType, T>
type Action<T extends string> = GetPendingAction<TeamActionType, T>
export class TeamActions {
constructor(protected readonly http: IHTTPClient<IAPIDef>) {}
fetchMyTeams = (): Action<'TEAMS_PENDING'> => {
return {
payload: this.http.get('/my/teams'),
type: 'TEAMS_PENDING',
}
fetchMyTeams = (): Action<'TEAMS'> => {
return new PendingAction(this.http.get('/my/teams'), 'TEAMS')
}
createTeam = (team: {name: string}): Action<'TEAM_CREATE_PENDING'> => {
return {
payload: this.http.post('/teams', team),
type: 'TEAM_CREATE_PENDING',
}
createTeam = (team: {name: string}): Action<'TEAM_CREATE'> => {
return new PendingAction(this.http.post('/teams', team), 'TEAM_CREATE')
}
updateTeam = ({id, name}: {id: number, name: string})
: Action<'TEAM_UPDATE_PENDING'> => {
return {
payload: this.http.put('/teams/:id', {name}, {id}),
type: 'TEAM_UPDATE_PENDING',
}
: Action<'TEAM_UPDATE'> => {
return new PendingAction(
this.http.put('/teams/:id', {name}, {id}),
'TEAM_UPDATE',
)
}
removeTeam = ({id}: {id: number}): Action<'TEAM_REMOVE_PENDING'> => {
return {
payload: this.http.delete('/teams/:id', {}, {id}),
type: 'TEAM_REMOVE_PENDING',
}
removeTeam = ({id}: {id: number}): Action<'TEAM_REMOVE'> => {
return new PendingAction(
this.http.delete('/teams/:id', {}, {id}),
'TEAM_REMOVE',
)
}
addUser(
@ -77,14 +47,14 @@ export class TeamActions {
teamId: number,
roleId: number,
})
: Action<'TEAM_USER_ADD_PENDING'> {
return {
payload: this.http.post('/teams/:teamId/users/:userId', {}, {
: Action<'TEAM_USER_ADD'> {
return new PendingAction(
this.http.post('/teams/:teamId/users/:userId', {}, {
userId,
teamId,
}),
type: 'TEAM_USER_ADD_PENDING',
}
'TEAM_USER_ADD',
)
}
removeUser = (
@ -92,33 +62,31 @@ export class TeamActions {
userId: number,
teamId: number,
})
: Action<'TEAM_USER_REMOVE_PENDING'> => {
return {
payload: this.http.delete('/teams/:teamId/users/:userId', {}, {
: Action<'TEAM_USER_REMOVE'> => {
return new PendingAction(
this.http.delete('/teams/:teamId/users/:userId', {}, {
userId,
teamId,
}),
type: 'TEAM_USER_REMOVE_PENDING',
}
'TEAM_USER_REMOVE',
)
}
fetchUsersInTeam = ({teamId}: {teamId: number})
: Action<'TEAM_USERS_PENDING'> => {
return {
payload: this.http.get('/teams/:teamId/users', {}, {
: Action<'TEAM_USERS'> => {
return new PendingAction(
this.http.get('/teams/:teamId/users', {}, {
teamId,
})
.then(usersInTeam => ({teamId, usersInTeam})),
type: 'TEAM_USERS_PENDING',
}
'TEAM_USERS',
)
}
findUserByEmail = (email: string): Action<'TEAM_USER_FIND_PENDING'> => {
return {
payload: this.http.get('/users/emails/:email', {}, {
email,
}),
type: 'TEAM_USER_FIND_PENDING',
}
findUserByEmail = (email: string): Action<'TEAM_USER_FIND'> => {
return new PendingAction(
this.http.get('/users/emails/:email', {}, {email}),
'TEAM_USER_FIND',
)
}
}

View File

@ -2,7 +2,7 @@ import {
ITeam, IUserInTeam, ReadonlyRecord, indexBy, without,
} from '@rondo/common'
import {TeamActionType} from './TeamActions'
import {GetAction} from '../actions'
import {GetResolvedAction} from '../actions'
export interface ITeamState {
readonly error: string
@ -26,7 +26,7 @@ const defaultState: ITeamState = {
function removeUser(
state: ITeamState,
action: GetAction<TeamActionType, 'TEAM_USER_REMOVE_RESOLVED'>,
action: GetResolvedAction<TeamActionType, 'TEAM_USER_REMOVE'>,
) {
const {payload} = action
@ -53,15 +53,24 @@ function getUserKey(userInTeam: {userId: number, teamId: number}) {
}
export function Team(state = defaultState, action: TeamActionType): ITeamState {
switch (action.status) {
case 'pending':
return state
case 'rejected':
return {
...state,
error: action.payload.message,
}
case 'resolved':
switch (action.type) {
case 'TEAMS_RESOLVED':
case 'TEAMS':
return {
...state,
teamIds: action.payload.map(team => team.id),
teamsById: indexBy(action.payload, 'id'),
}
case 'TEAM_CREATE_RESOLVED':
case 'TEAM_UPDATE_RESOLVED':
case 'TEAM_CREATE':
case 'TEAM_UPDATE':
return {
...state,
teamIds: state.teamIds.indexOf(action.payload.id) >= 0
@ -72,7 +81,7 @@ export function Team(state = defaultState, action: TeamActionType): ITeamState {
[action.payload.id]: action.payload,
},
}
case 'TEAM_USER_ADD_RESOLVED':
case 'TEAM_USER_ADD':
return {
...state,
userKeysByTeamId: {
@ -87,9 +96,9 @@ export function Team(state = defaultState, action: TeamActionType): ITeamState {
[getUserKey(action.payload)]: action.payload,
},
}
case 'TEAM_USER_REMOVE_RESOLVED':
case 'TEAM_USER_REMOVE':
return removeUser(state, action)
case 'TEAM_USERS_RESOLVED':
case 'TEAM_USERS':
const usersByKey = action.payload.usersInTeam
.reduce((obj, userInTeam) => {
obj[getUserKey(userInTeam)] = userInTeam
@ -108,20 +117,14 @@ export function Team(state = defaultState, action: TeamActionType): ITeamState {
...usersByKey,
},
}
case 'TEAM_REMOVE_RESOLVED':
case 'TEAM_REMOVE':
return {
...state,
teamIds: state.teamIds.filter(id => id !== action.payload.id),
teamsById: without(state.teamsById, action.payload.id),
}
case 'TEAM_CREATE_REJECTED':
case 'TEAM_UPDATE_REJECTED':
case 'TEAM_USER_ADD_REJECTED':
case 'TEAM_USER_REMOVE_REJECTED':
case 'TEAM_USERS_REJECTED':
return {
...state,
error: action.error.message,
default:
return state
}
default:
return state