Refactor action definitions to type less

This commit is contained in:
Jerko Steiner 2019-03-22 13:26:58 +08:00
parent f2e44f477c
commit 8f8c3b6c9c
20 changed files with 186 additions and 276 deletions

View File

@ -1,28 +0,0 @@
// Get the type of promise
// https://www.typescriptlang.org/docs/handbook/advanced-types.html
// section: Type inference in conditional types
type Unpacked<T> = T extends Promise<infer U> ? U : T
import {IAction} from './IAction'
import {IErrorAction} from './IErrorAction'
// Also from TypeScript handbook:
// https://www.typescriptlang.org/docs/handbook/advanced-types.html
type FunctionProperties<T> = {
[K in keyof T]:
T[K] extends (...args: any[]) => IAction<any, string>
? {
payload: Unpacked<ReturnType<T[K]>['payload']>,
type: Unpacked<ReturnType<T[K]>['type']>,
}
: T[K] extends (...args: any[]) => IErrorAction<string>
? {
error: Error,
type: Unpacked<ReturnType<T[K]>['type']>,
}
: never
}
// https://stackoverflow.com/questions/48305190/
// Is there an automatic way to create a discriminated union for all interfaces
// in a namespace?
export type ActionTypes<T> = FunctionProperties<T>[keyof FunctionProperties<T>]

View File

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

View File

@ -1,7 +1,3 @@
// Maybe this won't be necessary after this is merged: export interface IAction<ActionType extends string> {
// https://github.com/Microsoft/TypeScript/pull/29478
export interface IAction<T = any, ActionType extends string = string> {
payload: Promise<T> | T,
type: ActionType type: ActionType
} }

View File

@ -0,0 +1,12 @@
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>

View File

@ -1,4 +0,0 @@
export interface IErrorAction<ActionType extends string> {
error: Error,
type: ActionType
}

View File

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

View File

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

View File

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

View File

@ -1,13 +0,0 @@
// Get the type of promise
// https://www.typescriptlang.org/docs/handbook/advanced-types.html
// section: Type inference in conditional types
type Unpacked<T> = T extends Promise<infer U> ? U : T
type FunctionProperties<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any ? {
payload: Unpacked<ReturnType<T[K]>['payload']>,
type: Unpacked<ReturnType<T[K]>['type']>,
}: never
}
export type UnionType<T> = FunctionProperties<T>[keyof FunctionProperties<T>]

View File

@ -1,4 +1,6 @@
export * from './ActionTypes' export * from './GetAction'
export * from './IAction' export * from './IAction'
export * from './IErrorAction' export * from './IAsyncAction'
export * from './UnionType' export * from './IPendingAction'
export * from './IRejectedAction'
export * from './IResolvedAction'

View File

@ -1,4 +1,4 @@
import {IAction, IErrorAction, ActionTypes} from '../actions' import {GetAction, IAsyncAction, IResolvedAction} from '../actions'
import {IAPIDef, ICredentials, INewUser, IUser} from '@rondo/common' import {IAPIDef, ICredentials, INewUser, IUser} from '@rondo/common'
import {IHTTPClient} from '../http/IHTTPClient' import {IHTTPClient} from '../http/IHTTPClient'
@ -18,56 +18,51 @@ export enum LoginActionKeys {
LOGIN_REDIRECT_SET = 'LOGIN_REDIRECT_SET', 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'>
type Action<T extends string> = GetAction<LoginActionType, T>
export class LoginActions { export class LoginActions {
constructor(protected readonly http: IHTTPClient<IAPIDef>) {} constructor(protected readonly http: IHTTPClient<IAPIDef>) {}
logIn = (credentials: ICredentials) logIn = (credentials: ICredentials): Action<'LOGIN_PENDING'> => {
: IAction<IUser, LoginActionKeys.LOGIN> => {
return { return {
payload: this.http.post('/auth/login', credentials), payload: this.http.post('/auth/login', credentials),
type: LoginActionKeys.LOGIN, type: 'LOGIN_PENDING',
} }
} }
logInError = (error: Error) logOut = (): Action<'LOGIN_LOGOUT_PENDING'> => {
: IErrorAction<LoginActionKeys.LOGIN_REJECTED> => {
return {
error,
type: LoginActionKeys.LOGIN_REJECTED,
}
}
logOut = (): IAction<unknown, LoginActionKeys.LOGIN_LOG_OUT> => {
return { return {
payload: this.http.get('/auth/logout'), payload: this.http.get('/auth/logout'),
type: LoginActionKeys.LOGIN_LOG_OUT, type: 'LOGIN_LOGOUT_PENDING',
} }
} }
register = (profile: INewUser): register = (profile: INewUser): Action<'LOGIN_REGISTER_PENDING'> => {
IAction<IUser, LoginActionKeys.LOGIN_REGISTER> => {
return { return {
payload: this.http.post('/auth/register', profile), payload: this.http.post('/auth/register', profile),
type: LoginActionKeys.LOGIN_REGISTER, type: 'LOGIN_REGISTER_PENDING',
} }
} }
registerError = (error: Error) setRedirectTo = (redirectTo: string): Action<'LOGIN_REDIRECT_SET'> => {
: IErrorAction<LoginActionKeys.LOGIN_REGISTER_REJECTED> => {
return {
error,
type: LoginActionKeys.LOGIN_REGISTER_REJECTED,
}
}
setRedirectTo = (redirectTo: string)
: IAction<{redirectTo: string}, LoginActionKeys.LOGIN_REDIRECT_SET> => {
return { return {
payload: {redirectTo}, payload: {redirectTo},
type: LoginActionKeys.LOGIN_REDIRECT_SET, type: LoginActionKeys.LOGIN_REDIRECT_SET,
} }
} }
} }
// This makes it very easy to write reducer code.
export type LoginActionType = ActionTypes<LoginActions>

View File

@ -1,5 +1,5 @@
import {IUser} from '@rondo/common' import {IUser} from '@rondo/common'
import {LoginActionKeys, LoginActionType} from './LoginActions' import {LoginActionType} from './LoginActions'
export interface ILoginState { export interface ILoginState {
readonly error?: string readonly error?: string
@ -18,17 +18,17 @@ export function Login(
action: LoginActionType, action: LoginActionType,
): ILoginState { ): ILoginState {
switch (action.type) { switch (action.type) {
case LoginActionKeys.LOGIN: case 'LOGIN_RESOLVED':
return {...state, user: action.payload, error: ''} return {...state, user: action.payload, error: ''}
case LoginActionKeys.LOGIN_LOG_OUT: case 'LOGIN_LOGOUT_RESOLVED':
return {...state, user: undefined} return {...state, user: undefined}
case LoginActionKeys.LOGIN_REJECTED: case 'LOGIN_REJECTED':
return {...state, error: action.error.message} return {...state, error: action.error.message}
case LoginActionKeys.LOGIN_REGISTER: case 'LOGIN_REGISTER_RESOLVED':
return {...state, user: action.payload, error: ''} return {...state, user: action.payload, error: ''}
case LoginActionKeys.LOGIN_REGISTER_REJECTED: case 'LOGIN_REGISTER_REJECTED':
return {...state, error: action.error.message} return {...state, error: action.error.message}
case LoginActionKeys.LOGIN_REDIRECT_SET: case 'LOGIN_REDIRECT_SET':
return {...state, redirectTo: action.payload.redirectTo} return {...state, redirectTo: action.payload.redirectTo}
default: default:
return state return state

View File

@ -10,7 +10,7 @@ export interface IComponentProps<Data> {
} }
export interface IFormHOCProps<Data> { export interface IFormHOCProps<Data> {
onSubmit: (props: Data) => IAction<any, any> onSubmit: (props: Data) => IAction<any>
// TODO figure out what would happen if the underlying child component // TODO figure out what would happen if the underlying child component
// would have the same required property as the HOC, like onSuccess? // would have the same required property as the HOC, like onSuccess?
onSuccess?: () => void onSuccess?: () => void

View File

@ -26,21 +26,21 @@ describe('PromiseMiddleware', () => {
expect(store.getState().slice(1)).toEqual([action]) expect(store.getState().slice(1)).toEqual([action])
}) })
it('dispatches pending and fulfilled action', async () => { it('dispatches pending and resolved action', async () => {
const value = 123 const value = 123
const type = 'TEST' const type = 'TEST'
const action = { const action = {
payload: Promise.resolve(value), payload: Promise.resolve(value),
type, type: `${type}_PENDING`,
} }
const result = store.dispatch(action) const result = store.dispatch(action)
expect(result).toBe(action) expect(result).toBe(action)
await result.payload await result.payload
expect(store.getState().slice(1)).toEqual([{ expect(store.getState().slice(1)).toEqual([{
type: `${type}_PENDING`, ...action,
}, { }, {
payload: value, payload: value,
type, type: type + '_RESOLVED',
}]) }])
}) })
@ -49,14 +49,14 @@ describe('PromiseMiddleware', () => {
const type = 'TEST' const type = 'TEST'
const action = { const action = {
payload: Promise.reject(error), payload: Promise.reject(error),
type, type: `${type}_PENDING`,
} }
const result = store.dispatch(action) const result = store.dispatch(action)
expect(result).toBe(action) expect(result).toBe(action)
const err = await getError(result.payload) const err = await getError(result.payload)
expect(err).toBe(error) expect(err).toBe(error)
expect(store.getState().slice(1)).toEqual([{ expect(store.getState().slice(1)).toEqual([{
type: `${type}_PENDING`, ...action,
}, { }, {
error, error,
type: `${type}_REJECTED`, type: `${type}_REJECTED`,

View File

@ -11,7 +11,7 @@ function isPromise(value: any): value is Promise<any> {
* *
* If `action.payload` is a `Promise`, it will be handled by this class. It * If `action.payload` is a `Promise`, it will be handled by this class. It
* differs from other promise middlewares for redux because by default it does * differs from other promise middlewares for redux because by default it does
* not add an extension to action dispatched after a promise is fulfilled. This * not add an extension to action dispatched after a promise is resolved. This
* makes it easier to infer types from the API endpoints so they can be used in * makes it easier to infer types from the API endpoints so they can be used in
* both Action creators and Reducers. * both Action creators and Reducers.
* *
@ -20,36 +20,42 @@ function isPromise(value: any): value is Promise<any> {
* const middleware = applyMiddleware(new PromiseMiddleware().handle) * const middleware = applyMiddleware(new PromiseMiddleware().handle)
*/ */
export class PromiseMiddleware { export class PromiseMiddleware {
protected regexp: RegExp
constructor( constructor(
readonly pendingExtension = '_PENDING', readonly pendingExtension = '_PENDING',
readonly fulfilledExtension = '', readonly resolvedExtension = '_RESOLVED',
readonly rejectedExtension = '_REJECTED', readonly rejectedExtension = '_REJECTED',
) { ) {
assert( assert(
this.pendingExtension !== this.fulfilledExtension && this.pendingExtension !== this.resolvedExtension &&
this.fulfilledExtension !== this.rejectedExtension && this.resolvedExtension !== this.rejectedExtension &&
this.pendingExtension !== this.rejectedExtension, this.pendingExtension !== this.rejectedExtension,
'Pending, fulfilled and rejected extensions must be unique') 'Pending, resolved and rejected extensions must be unique')
this.regexp = new RegExp(pendingExtension + '$')
} }
handle: Middleware = store => next => (action: AnyAction) => { handle: Middleware = store => next => (action: AnyAction) => {
const {payload, type} = action const {payload, type} = action
// Propagate this action. Only attach listeners to the promise.
next(action)
if (!isPromise(payload)) { if (!isPromise(payload)) {
next(action)
return return
} }
store.dispatch({type: type + this.pendingExtension})
const strippedType = type.replace(this.regexp, '')
payload payload
.then(result => { .then(result => {
store.dispatch({ store.dispatch({
payload: result, payload: result,
type, type: strippedType + this.resolvedExtension,
}) })
}) })
.catch(err => { .catch(err => {
store.dispatch({ store.dispatch({
error: err, error: err,
type: type + this.rejectedExtension, type: strippedType + this.rejectedExtension,
}) })
}) })

View File

@ -1,105 +1,73 @@
import {IAPIDef} from '@rondo/common' import {IAPIDef} from '@rondo/common'
import {IAction, IErrorAction, ActionTypes} from '../actions' import {GetAction, IAsyncAction} from '../actions'
import {IHTTPClient} from '../http/IHTTPClient' import {IHTTPClient} from '../http/IHTTPClient'
import {ITeam, IUser, IUserInTeam} from '@rondo/common' import {ITeam, IUser, IUserInTeam} from '@rondo/common'
export enum TeamActionKeys { export type TeamActionType =
TEAMS = 'TEAMS', IAsyncAction<ITeam[],
TEAMS_PENDING = 'TEAMS_PENDING', 'TEAMS_PENDING',
TEAMS_REJECTED = 'TEAMS_REJECTED', '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<{},
'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'>
TEAM_CREATE = 'TEAM_CREATE', type Action<T extends string> = GetAction<TeamActionType, T>
TEAM_CREATE_PENDING = 'TEAM_CREATE_PENDING',
TEAM_CREATE_REJECTED = 'TEAM_CREATE_REJECTED',
TEAM_UPDATE = 'TEAM_UPDATE',
TEAM_UPDATE_PENDING = 'TEAM_UPDATE_PENDING',
TEAM_UPDATE_REJECTED = 'TEAM_UPDATE_REJECTED',
TEAM_REMOVE = 'TEAM_REMOVE',
TEAM_REMOVE_PENDING = 'TEAM_REMOVE_PENDING',
TEAM_REMOVE_REJECTED = 'TEAM_REMOVE_REJECTED',
TEAM_USER_ADD = 'TEAM_USER_ADD',
TEAM_USER_ADD_PENDING = 'TEAM_USER_ADD_PENDING',
TEAM_USER_ADD_REJECTED = 'TEAM_USER_ADD_REJECTED',
TEAM_USER_REMOVE = 'TEAM_USER_REMOVE',
TEAM_USER_REMOVE_PENDING = 'TEAM_USER_REMOVE_PENDING',
TEAM_USER_REMOVE_REJECTED = 'TEAM_USER_REMOVE_REJECTED',
TEAM_USERS = 'TEAM_USERS',
TEAM_USERS_PENDING = 'TEAM_USERS_PENDING',
TEAM_USERS_REJECTED = 'TEAM_USERS_REJECTED',
TEAM_USER_FIND = 'TEAM_USER_FIND',
TEAM_USER_FIND_PENDING = 'TEAM_USER_FIND_PENDING',
TEAM_USER_FIND_REJECTED = 'TEAM_USER_FIND_REJECTED',
}
export class TeamActions { export class TeamActions {
constructor(protected readonly http: IHTTPClient<IAPIDef>) {} constructor(protected readonly http: IHTTPClient<IAPIDef>) {}
fetchMyTeams = (): IAction<ITeam[], TeamActionKeys.TEAMS> => { fetchMyTeams = (): Action<'TEAMS_PENDING'> => {
return { return {
payload: this.http.get('/my/teams'), payload: this.http.get('/my/teams'),
type: TeamActionKeys.TEAMS, type: 'TEAMS_PENDING',
} }
} }
fetchMyTeamsError = (error: Error) createTeam = (team: {name: string}): Action<'TEAM_CREATE_PENDING'> => {
: IErrorAction<TeamActionKeys.TEAMS_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAMS_REJECTED,
}
}
createTeam = (team: {name: string})
: IAction<ITeam, TeamActionKeys.TEAM_CREATE> => {
return { return {
payload: this.http.post('/teams', team), payload: this.http.post('/teams', team),
type: TeamActionKeys.TEAM_CREATE, type: 'TEAM_CREATE_PENDING',
}
}
createTeamError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_CREATE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_CREATE_REJECTED,
} }
} }
updateTeam = ({id, name}: {id: number, name: string}) updateTeam = ({id, name}: {id: number, name: string})
: IAction<ITeam, TeamActionKeys.TEAM_UPDATE> => { : Action<'TEAM_UPDATE_PENDING'> => {
return { return {
payload: this.http.put('/teams/:id', {name}, {id}), payload: this.http.put('/teams/:id', {name}, {id}),
type: TeamActionKeys.TEAM_UPDATE, type: 'TEAM_UPDATE_PENDING',
} }
} }
updateTeamError = (error: Error) removeTeam = ({id}: {id: number}): Action<'TEAM_REMOVE_PENDING'> => {
: IErrorAction<TeamActionKeys.TEAM_UPDATE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_UPDATE_REJECTED,
}
}
removeTeam = ({id}: {id: number})
: IAction<{}, TeamActionKeys.TEAM_REMOVE> => {
return { return {
payload: this.http.delete('/teams/:id', {}, {id}), payload: this.http.delete('/teams/:id', {}, {id}),
type: TeamActionKeys.TEAM_REMOVE, type: 'TEAM_REMOVE_PENDING',
}
}
removeTeamError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_REMOVE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_REMOVE_REJECTED,
} }
} }
@ -109,21 +77,13 @@ export class TeamActions {
teamId: number, teamId: number,
roleId: number, roleId: number,
}) })
: IAction<IUserInTeam, TeamActionKeys.TEAM_USER_ADD> { : Action<'TEAM_USER_ADD_PENDING'> {
return { return {
payload: this.http.post('/teams/:teamId/users/:userId', {}, { payload: this.http.post('/teams/:teamId/users/:userId', {}, {
userId, userId,
teamId, teamId,
}), }),
type: TeamActionKeys.TEAM_USER_ADD, type: 'TEAM_USER_ADD_PENDING',
}
}
addUserError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USER_ADD_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USER_ADD_REJECTED,
} }
} }
@ -132,66 +92,33 @@ export class TeamActions {
userId: number, userId: number,
teamId: number, teamId: number,
}) })
: IAction<{ : Action<'TEAM_USER_REMOVE_PENDING'> => {
userId: number,
teamId: number,
}, TeamActionKeys.TEAM_USER_REMOVE> => {
return { return {
payload: this.http.delete('/teams/:teamId/users/:userId', {}, { payload: this.http.delete('/teams/:teamId/users/:userId', {}, {
userId, userId,
teamId, teamId,
}), }),
type: TeamActionKeys.TEAM_USER_REMOVE, type: 'TEAM_USER_REMOVE_PENDING',
}
}
removeUserError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USER_REMOVE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USER_REMOVE_REJECTED,
} }
} }
fetchUsersInTeam = ({teamId}: {teamId: number}) fetchUsersInTeam = ({teamId}: {teamId: number})
: IAction<{ : Action<'TEAM_USERS_PENDING'> => {
teamId: number,
usersInTeam: IUserInTeam[]
}, TeamActionKeys.TEAM_USERS> => {
return { return {
payload: this.http.get('/teams/:teamId/users', { payload: this.http.get('/teams/:teamId/users', {
teamId, teamId,
}) })
.then(usersInTeam => ({teamId, usersInTeam})), .then(usersInTeam => ({teamId, usersInTeam})),
type: TeamActionKeys.TEAM_USERS, type: 'TEAM_USERS_PENDING',
} }
} }
fetchUsersInTeamError = (error: Error) findUserByEmail = (email: string): Action<'TEAM_USER_FIND_PENDING'> => {
: IErrorAction<TeamActionKeys.TEAM_USERS_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USERS_REJECTED,
}
}
findUserByEmail = (email: string)
: IAction<IUser | undefined, TeamActionKeys.TEAM_USER_FIND> => {
return { return {
payload: this.http.get('/users/emails/:email', { payload: this.http.get('/users/emails/:email', {
email, email,
}), }),
type: TeamActionKeys.TEAM_USER_FIND, type: 'TEAM_USER_FIND_PENDING',
}
}
findUserByEmailError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USER_FIND_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USER_FIND_REJECTED,
} }
} }
} }
export type TeamActionType = ActionTypes<TeamActions>

View File

@ -1,26 +1,26 @@
import React from 'react' import React from 'react'
import {IAction} from '../actions'
import {ITeam, ReadonlyRecord} from '@rondo/common' import {ITeam, ReadonlyRecord} from '@rondo/common'
import {TeamActions} from './TeamActions'
export interface ITeamListProps { export interface ITeamListProps {
teamsById: ReadonlyRecord<number, ITeam>, teamsById: ReadonlyRecord<number, ITeam>,
teamIds: ReadonlyArray<number>, teamIds: ReadonlyArray<number>,
onAddTeam: (params: {name: string}) => IAction onAddTeam: TeamActions['createTeam']
onRemoveTeam: (params: {id: number}) => IAction onRemoveTeam: TeamActions['removeTeam']
onUpdateTeam: (params: {id: number, name: string}) => IAction onUpdateTeam: TeamActions['updateTeam']
editTeamId: number editTeamId: number
} }
export interface ITeamProps { export interface ITeamProps {
team: ITeam team: ITeam
editTeamId: number // TODO handle edits via react-router params editTeamId: number // TODO handle edits via react-router params
onRemoveTeam: (params: {id: number}) => IAction onRemoveTeam: TeamActions['removeTeam']
onUpdateTeam: (params: {id: number, name: string}) => IAction onUpdateTeam: TeamActions['updateTeam']
} }
export interface IAddTeamProps { export interface IAddTeamProps {
onAddTeam: (params: {name: string}) => IAction onAddTeam: TeamActions['createTeam']
onUpdateTeam: (params: {id: number, name: string}) => IAction onUpdateTeam: TeamActions['updateTeam']
team?: ITeam team?: ITeam
} }

View File

@ -1,19 +1,19 @@
import React from 'react' import React from 'react'
import {IAction} from '../actions' import {ITeam, IUserInTeam, ReadonlyRecord} from '@rondo/common'
import {ITeam, IUser, IUserInTeam, ReadonlyRecord} from '@rondo/common'
import {TeamList} from './TeamList' import {TeamList} from './TeamList'
import {TeamUserList} from './TeamUserList' import {TeamUserList} from './TeamUserList'
import {TeamActions} from './TeamActions'
export interface ITeamManagerProps { export interface ITeamManagerProps {
createTeam: (params: {name: string}) => IAction createTeam: TeamActions['createTeam']
updateTeam: (params: {id: number, name: string}) => IAction updateTeam: TeamActions['updateTeam']
removeTeam: (params: {id: number}) => IAction removeTeam: TeamActions['removeTeam']
addUser: (params: {userId: number, teamId: number, roleId: number}) => IAction addUser: TeamActions['addUser']
removeUser: (params: {userId: number, teamId: number}) => IAction removeUser: TeamActions['removeUser']
fetchMyTeams: () => IAction fetchMyTeams: TeamActions['fetchMyTeams']
fetchUsersInTeam: (params: {teamId: number}) => IAction fetchUsersInTeam: TeamActions['fetchUsersInTeam']
findUserByEmail: (email: string) => IAction<IUser | undefined> findUserByEmail: TeamActions['findUserByEmail']
teamsById: ReadonlyRecord<number, ITeam> teamsById: ReadonlyRecord<number, ITeam>
teamIds: ReadonlyArray<number> teamIds: ReadonlyArray<number>

View File

@ -1,5 +1,6 @@
import {ITeam, IUserInTeam, ReadonlyRecord, indexBy} from '@rondo/common' import {ITeam, IUserInTeam, ReadonlyRecord, indexBy} from '@rondo/common'
import {TeamActionKeys, TeamActionType} from './TeamActions' import {TeamActionType} from './TeamActions'
import {GetAction} from '../actions'
export interface ITeamState { export interface ITeamState {
readonly error: string readonly error: string
@ -23,10 +24,7 @@ const defaultState: ITeamState = {
function removeUser( function removeUser(
state: ITeamState, state: ITeamState,
action: { action: GetAction<TeamActionType, 'TEAM_USER_REMOVE_RESOLVED'>,
payload: {userId: number, teamId: number},
type: TeamActionKeys.TEAM_USER_REMOVE,
},
) { ) {
const {payload} = action const {payload} = action
@ -54,14 +52,14 @@ function getUserKey(userInTeam: {userId: number, teamId: number}) {
export function Team(state = defaultState, action: TeamActionType): ITeamState { export function Team(state = defaultState, action: TeamActionType): ITeamState {
switch (action.type) { switch (action.type) {
case TeamActionKeys.TEAMS: case 'TEAMS_RESOLVED':
return { return {
...state, ...state,
teamIds: action.payload.map(team => team.id), teamIds: action.payload.map(team => team.id),
teamsById: indexBy(action.payload, 'id'), teamsById: indexBy(action.payload, 'id'),
} }
case TeamActionKeys.TEAM_CREATE: case 'TEAM_CREATE_RESOLVED':
case TeamActionKeys.TEAM_UPDATE: case 'TEAM_UPDATE_RESOLVED':
return { return {
...state, ...state,
teamIds: state.teamIds.indexOf(action.payload.id) >= 0 teamIds: state.teamIds.indexOf(action.payload.id) >= 0
@ -73,7 +71,7 @@ export function Team(state = defaultState, action: TeamActionType): ITeamState {
}, },
} }
return state return state
case TeamActionKeys.TEAM_USER_ADD: case 'TEAM_USER_ADD_RESOLVED':
return { return {
...state, ...state,
userKeysByTeamId: { userKeysByTeamId: {
@ -88,9 +86,9 @@ export function Team(state = defaultState, action: TeamActionType): ITeamState {
[getUserKey(action.payload)]: action.payload, [getUserKey(action.payload)]: action.payload,
}, },
} }
case TeamActionKeys.TEAM_USER_REMOVE: case 'TEAM_USER_REMOVE_RESOLVED':
return removeUser(state, action) return removeUser(state, action)
case TeamActionKeys.TEAM_USERS: case 'TEAM_USERS_RESOLVED':
const usersByKey = action.payload.usersInTeam const usersByKey = action.payload.usersInTeam
.reduce((obj, userInTeam) => { .reduce((obj, userInTeam) => {
obj[getUserKey(userInTeam)] = userInTeam obj[getUserKey(userInTeam)] = userInTeam
@ -109,11 +107,11 @@ export function Team(state = defaultState, action: TeamActionType): ITeamState {
...usersByKey, ...usersByKey,
}, },
} }
case TeamActionKeys.TEAM_CREATE_REJECTED: case 'TEAM_CREATE_REJECTED':
case TeamActionKeys.TEAM_UPDATE_REJECTED: case 'TEAM_UPDATE_REJECTED':
case TeamActionKeys.TEAM_USER_ADD_REJECTED: case 'TEAM_USER_ADD_REJECTED':
case TeamActionKeys.TEAM_USER_REMOVE_REJECTED: case 'TEAM_USER_REMOVE_REJECTED':
case TeamActionKeys.TEAM_USERS_REJECTED: case 'TEAM_USERS_REJECTED':
return { return {
...state, ...state,
error: action.error.message, error: action.error.message,

View File

@ -1,17 +1,16 @@
import React from 'react' import React from 'react'
import {IUser, IUserInTeam, ReadonlyRecord} from '@rondo/common' import {IUser, IUserInTeam, ReadonlyRecord} from '@rondo/common'
import {IAction} from '../actions' import {TeamActions} from './TeamActions'
const EMPTY_ARRAY: ReadonlyArray<string> = [] const EMPTY_ARRAY: ReadonlyArray<string> = []
export interface ITeamUsersProps { export interface ITeamUsersProps {
// fetchMyTeams: () => void, // fetchMyTeams: () => void,
fetchUsersInTeam: (params: {teamId: number}) => IAction fetchUsersInTeam: TeamActions['fetchUsersInTeam']
findUserByEmail: (email: string) => IAction findUserByEmail: TeamActions['findUserByEmail']
onAddUser: (params: {userId: number, teamId: number, roleId: number}) onAddUser: TeamActions['addUser']
=> IAction<IUserInTeam> onRemoveUser: TeamActions['removeUser']
onRemoveUser: (params: {userId: number, teamId: number}) => IAction
teamId: number teamId: number
userKeysByTeamId: ReadonlyRecord<number, ReadonlyArray<string>> userKeysByTeamId: ReadonlyRecord<number, ReadonlyArray<string>>
@ -24,12 +23,8 @@ export interface ITeamUserProps {
} }
export interface IAddUserProps { export interface IAddUserProps {
onAddUser: (params: { onAddUser: TeamActions['addUser']
userId: number, onSearchUser: TeamActions['findUserByEmail']
teamId: number,
roleId: number,
}) => IAction<IUserInTeam>
onSearchUser: (email: string) => IAction<IUser>
teamId: number teamId: number
} }