Add packages/client/src/team

This commit is contained in:
Jerko Steiner 2019-03-20 15:01:15 +05:00
parent 9aff78b7a9
commit a628082a73
15 changed files with 727 additions and 2 deletions

View File

@ -0,0 +1,197 @@
import {IAPIDef} from '@rondo/common'
import {IAction, IErrorAction, ActionTypes} from '../actions'
import {IHTTPClient} from '../http/IHTTPClient'
import {ITeam, IUser, IUserInTeam} from '@rondo/common'
export enum TeamActionKeys {
TEAMS = 'TEAMS',
TEAMS_PENDING = 'TEAMS_PENDING',
TEAMS_REJECTED = 'TEAMS_REJECTED',
TEAM_CREATE = 'TEAM_CREATE',
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 {
constructor(protected readonly http: IHTTPClient<IAPIDef>) {}
fetchMyTeams = (): IAction<ITeam[], TeamActionKeys.TEAMS> => {
return {
payload: this.http.get('/my/teams'),
type: TeamActionKeys.TEAMS,
}
}
fetchMyTeamsError = (error: Error)
: IErrorAction<TeamActionKeys.TEAMS_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAMS_REJECTED,
}
}
createTeam = (team: {name: string})
: IAction<ITeam, TeamActionKeys.TEAM_CREATE> => {
return {
payload: this.http.post('/teams', team),
type: TeamActionKeys.TEAM_CREATE,
}
}
createTeamError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_CREATE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_CREATE_REJECTED,
}
}
updateTeam = ({id, name}: {id: number, name: string})
: IAction<ITeam, TeamActionKeys.TEAM_UPDATE> => {
return {
payload: this.http.put('/teams/:id', {name}, {id}),
type: TeamActionKeys.TEAM_UPDATE,
}
}
updateTeamError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_UPDATE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_UPDATE_REJECTED,
}
}
removeTeam = ({id, name}: {id: number, name: string})
: IAction<{}, TeamActionKeys.TEAM_REMOVE> => {
return {
payload: this.http.delete('/teams/:id', {}, {id}),
type: TeamActionKeys.TEAM_REMOVE,
}
}
removeTeamError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_REMOVE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_REMOVE_REJECTED,
}
}
addUser(
{userId, teamId, roleId = 1}: {
userId: number,
teamId: number,
roleId: number,
})
: IAction<IUserInTeam, TeamActionKeys.TEAM_USER_ADD> {
return {
payload: this.http.post('/teams/:teamId/users/:userId', {}, {
userId,
teamId,
}),
type: TeamActionKeys.TEAM_USER_ADD,
}
}
addUserError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USER_ADD_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USER_ADD_REJECTED,
}
}
removeUser = (
{userId, teamId}: {
userId: number,
teamId: number,
})
: IAction<{
userId: number,
teamId: number,
}, TeamActionKeys.TEAM_USER_REMOVE> => {
return {
payload: this.http.delete('/teams/:teamId/users/:userId', {}, {
userId,
teamId,
}),
type: TeamActionKeys.TEAM_USER_REMOVE,
}
}
removeUserError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USER_REMOVE_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USER_REMOVE_REJECTED,
}
}
fetchUsersInTeam = ({teamId}: {teamId: number})
: IAction<{
teamId: number,
usersInTeam: IUserInTeam[]
}, TeamActionKeys.TEAM_USERS> => {
return {
payload: this.http.get('/teams/:teamId/users', {
teamId,
})
.then(usersInTeam => ({teamId, usersInTeam})),
type: TeamActionKeys.TEAM_USERS,
}
}
fetchUsersInTeamError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USERS_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USERS_REJECTED,
}
}
findUserByEmail = (email: string)
: IAction<IUser | undefined, TeamActionKeys.TEAM_USER_FIND> => {
return {
payload: this.http.get('/users/emails/:email', {
email,
}),
type: TeamActionKeys.TEAM_USER_FIND,
}
}
findUserByEmailError = (error: Error)
: IErrorAction<TeamActionKeys.TEAM_USER_FIND_REJECTED> => {
return {
error,
type: TeamActionKeys.TEAM_USER_FIND_REJECTED,
}
}
}
export type TeamActionType = ActionTypes<TeamActions>

View File

@ -0,0 +1,24 @@
import * as Feature from './'
// export ReactDOM from 'react-dom'
// import T from 'react-dom/test-utils'
import {HTTPClientMock, TestUtils/*, getError*/} from '../test-utils'
import {IAPIDef} from '@rondo/common'
const test = new TestUtils()
describe('TeamConnector', () => {
const http = new HTTPClientMock<IAPIDef>()
const teamActions = new Feature.TeamActions(http)
const createTestProvider = () => test.withProvider({
reducers: {Team: Feature.Team},
connector: new Feature.TeamConnector(teamActions),
select: state => state.Team,
})
it('should render', () => {
createTestProvider().render()
})
})

View File

@ -0,0 +1,32 @@
import {Connector} from '../redux/Connector'
import {ITeamState} from './TeamReducer'
import {IStateSelector} from '../redux'
import {TeamActions} from './TeamActions'
import {TeamManager} from './TeamManager'
import {bindActionCreators} from 'redux'
export class TeamConnector extends Connector<ITeamState> {
constructor(protected readonly teamActions: TeamActions) {
super()
}
connect<State>(getLocalState: IStateSelector<State, ITeamState>) {
return this.wrap(
getLocalState,
state => ({
...state,
}),
d => ({
addUser: bindActionCreators(this.teamActions.addUser, d),
removeUser: bindActionCreators(this.teamActions.removeUser, d),
createTeam: bindActionCreators(this.teamActions.createTeam, d),
updateTeam: bindActionCreators(this.teamActions.updateTeam, d),
removeTeam: bindActionCreators(this.teamActions.removeTeam, d),
fetchMyTeams: bindActionCreators(this.teamActions.fetchMyTeams, d),
fetchUsersInTeam:
bindActionCreators(this.teamActions.fetchUsersInTeam, d),
}),
TeamManager,
)
}
}

View File

@ -0,0 +1,117 @@
import React from 'react'
import {ITeam, ReadonlyRecord} from '@rondo/common'
export interface ITeamListProps {
teamsById: ReadonlyRecord<number, ITeam>,
teamIds: ReadonlyArray<number>,
onAddTeam: (params: {name: string}) => Promise<void>
onRemoveTeam: (params: {teamId: number}) => Promise<void>
onUpdateTeam: (params: {id: number, name: string}) => Promise<void>
editTeamId: number
}
export interface ITeamProps {
team: ITeam
editTeamId: number // TODO handle edits via react-router params
onRemoveTeam: (params: {teamId: number}) => Promise<void>
onUpdateTeam: (params: {id: number, name: string}) => Promise<void>
}
export interface IAddTeamProps {
onAddTeam: (params: {name: string}) => Promise<void>
onUpdateTeam: (params: {id: number, name: string}) => Promise<void>
team?: ITeam
}
export interface IAddTeamState {
name: string
}
export class TeamAdd extends React.PureComponent<IAddTeamProps, IAddTeamState> {
constructor(props: IAddTeamProps) {
super(props)
this.state = {
name: '',
}
}
handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const name = event.target.value
this.setState({name})
}
handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
const {team, onAddTeam, onUpdateTeam} = this.props
const {name} = this.state
if (team) {
await onUpdateTeam({id: team.id, name})
} else {
await onAddTeam({name})
}
}
render() {
return (
<form className='team-add' onSubmit={this.handleSubmit}>
{this.props.team ? 'Edit team' : 'Add team'}
<input
type='text'
value={this.state.name}
onChange={this.handleChange}
/>
<input
type='submit'
value='Save'
/>
</form>
)
}
}
export class TeamRow extends React.PureComponent<ITeamProps> {
handleRemove = async () => {
const {onRemoveTeam, team: {id: teamId}} = this.props
await onRemoveTeam({teamId})
}
render() {
const {team} = this.props
return (
<div className='team'>
{team.id}
{this.props.editTeamId !== team.id
? team.name
: <TeamAdd
onAddTeam={undefined as any}
onUpdateTeam={this.props.onUpdateTeam}
team={team}
/>
}
<button onClick={this.handleRemove}>Remove</button>
</div>
)
}
}
export class TeamList extends React.PureComponent<ITeamListProps> {
render() {
const {editTeamId, teamIds, teamsById} = this.props
return (
<div className='team-list'>
{teamIds.map(teamId => {
const team = teamsById[teamId]
return (
<TeamRow
editTeamId={editTeamId}
onRemoveTeam={this.props.onRemoveTeam}
onUpdateTeam={this.props.onUpdateTeam}
team={team}
/>
)
})}
<TeamAdd
onAddTeam={this.props.onAddTeam}
onUpdateTeam={undefined as any}
/>
</div>
)
}
}

View File

@ -0,0 +1,62 @@
import React from 'react'
import {ITeam, IUser, IUserInTeam, ReadonlyRecord} from '@rondo/common'
import {TeamList} from './TeamList'
import {TeamUserList} from './TeamUserList'
// import {Route} from 'react-router-dom'
export interface ITeamManagerProps {
createTeam: (params: {name: string}) => Promise<void>
updateTeam: (params: {id: number, name: string}) => Promise<void>
removeTeam: (params: {teamId: number}) => Promise<void>
addUser: (params: {userId: number, teamId: number}) => Promise<void>
removeUser: (params: {userId: number, teamId: number}) => Promise<void>
fetchMyTeams: () => void
fetchUsersInTeam: () => void
findUserByEmail: (email: string) => Promise<IUser>
teamsById: ReadonlyRecord<number, ITeam>
teamIds: ReadonlyArray<number>
editTeamId: number
userKeysByTeamId: ReadonlyRecord<number, ReadonlyArray<string>>
usersByKey: ReadonlyRecord<string, IUserInTeam>
}
export class TeamManager extends React.PureComponent<ITeamManagerProps> {
async componentDidMount() {
await this.props.fetchMyTeams()
}
render() {
// TODO load my teams on first launch
// TODO use teamId from route url
// TODO use editTeamId from route url
const {editTeamId} = this.props
return (
<React.Fragment>
<TeamList
editTeamId={this.props.editTeamId}
teamsById={this.props.teamsById}
teamIds={this.props.teamIds}
onAddTeam={this.props.createTeam}
onRemoveTeam={this.props.removeTeam}
onUpdateTeam={this.props.updateTeam}
/>
<TeamUserList
onAddUser={this.props.addUser}
onRemoveUser={this.props.removeUser}
findUserByEmail={this.props.findUserByEmail}
fetchUsersInTeam={this.props.fetchUsersInTeam}
teamId={editTeamId}
userKeysByTeamId={this.props.userKeysByTeamId}
usersByKey={this.props.usersByKey}
/>
</React.Fragment>
)
}
}

View File

@ -0,0 +1,125 @@
import {ITeam, IUserInTeam, ReadonlyRecord, indexBy} from '@rondo/common'
import {TeamActionKeys, TeamActionType} from './TeamActions'
export interface ITeamState {
readonly error: string
readonly teamIds: ReadonlyArray<number>
readonly teamsById: ReadonlyRecord<number, ITeam>
readonly userKeysByTeamId: ReadonlyRecord<number, ReadonlyArray<string>>
readonly usersByKey: ReadonlyRecord<string, IUserInTeam>
}
const defaultState: ITeamState = {
error: '',
teamIds: [],
teamsById: {},
userKeysByTeamId: {},
usersByKey: {},
}
function removeUser(
state: ITeamState,
action: {
payload: {userId: number, teamId: number},
type: TeamActionKeys.TEAM_USER_REMOVE,
},
) {
const {payload} = action
const {teamId} = payload
const userKey = getUserKey(payload)
const userKeysByTeamId = {
...state.userKeysByTeamId,
[teamId]: state.userKeysByTeamId[teamId].filter(u => u !== userKey),
}
const usersByKey = {...state.usersByKey}
delete usersByKey[getUserKey(payload)]
return {
...state,
userKeysByTeamId,
usersByKey,
}
}
function getUserKey(userInTeam: {userId: number, teamId: number}) {
return `${userInTeam.teamId}_${userInTeam.userId}`
}
export function Team(state = defaultState, action: TeamActionType): ITeamState {
switch (action.type) {
case TeamActionKeys.TEAMS:
return {
...state,
teamIds: action.payload.map(team => team.id),
teamsById: indexBy(action.payload, 'id'),
}
case TeamActionKeys.TEAM_CREATE:
case TeamActionKeys.TEAM_UPDATE:
return {
...state,
teamIds: state.teamIds.indexOf(action.payload.id) >= 0
? state.teamIds
: [...state.teamIds, action.payload.id],
teamsById: {
...state.teamsById,
[action.payload.id]: action.payload,
},
}
return state
case TeamActionKeys.TEAM_USER_ADD:
return {
...state,
userKeysByTeamId: {
...state.userKeysByTeamId,
[action.payload.teamId]: [
...state.userKeysByTeamId[action.payload.teamId],
getUserKey(action.payload),
],
},
usersByKey: {
...state.usersByKey,
[getUserKey(action.payload)]: action.payload,
},
}
case TeamActionKeys.TEAM_USER_REMOVE:
return removeUser(state, action)
case TeamActionKeys.TEAM_USERS:
const usersByKey = action.payload.usersInTeam
.reduce((obj, userInTeam) => {
obj[getUserKey(userInTeam)] = userInTeam
return obj
}, {} as Record<string, IUserInTeam>)
return {
...state,
userKeysByTeamId: {
...state.userKeysByTeamId,
[action.payload.teamId]: action.payload.usersInTeam
.map(ut => getUserKey(ut)),
},
usersByKey: {
...state.usersByKey,
...usersByKey,
},
}
case TeamActionKeys.TEAM_CREATE_REJECTED:
case TeamActionKeys.TEAM_UPDATE_REJECTED:
case TeamActionKeys.TEAM_USER_ADD_REJECTED:
case TeamActionKeys.TEAM_USER_REMOVE_REJECTED:
case TeamActionKeys.TEAM_USERS_REJECTED:
return {
...state,
error: action.error.message,
}
default:
return state
}
return state
}

View File

@ -0,0 +1,143 @@
import React from 'react'
import {IUser, IUserInTeam, ReadonlyRecord} from '@rondo/common'
const EMPTY_ARRAY: ReadonlyArray<string> = []
export interface ITeamUsersProps {
// fetchMyTeams: () => void,
fetchUsersInTeam: (teamId: number) => void
findUserByEmail: (email: string) => Promise<IUser>
onAddUser: (params: {userId: number, teamId: number}) => Promise<void>
onRemoveUser: (params: {userId: number, teamId: number}) => Promise<void>
teamId: number
userKeysByTeamId: ReadonlyRecord<number, ReadonlyArray<string>>
usersByKey: ReadonlyRecord<string, IUserInTeam>
}
export interface ITeamUserProps {
onRemoveUser: (params: {userId: number, teamId: number}) => void
user: IUserInTeam
}
export interface IAddUserProps {
onAddUser: (params: {userId: number, teamId: number}) => Promise<void>
onSearchUser: (email: string) => Promise<IUser>
teamId: number
}
export interface IAddUserState {
email: string
user?: IUser
}
export class TeamUser extends React.PureComponent<ITeamUserProps> {
handleRemoveUser = async () => {
const {onRemoveUser, user} = this.props
await onRemoveUser(user)
}
render() {
const {user} = this.props
// TODO style
return (
<div className='team-user'>
{user.displayName}
<button
className='team-user-remove'
onClick={this.handleRemoveUser}
>
Remove
</button>
</div>
)
}
}
export class AddUser extends React.PureComponent<IAddUserProps, IAddUserState> {
constructor(props: IAddUserProps) {
super(props)
this.state = {
email: '',
user: undefined,
}
}
handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
const email = event.target.value
this.setState({email})
}
handleAddUser = async (event: React.FormEvent) => {
event.preventDefault()
const {teamId} = this.props
const {email} = this.state
const user = await this.props.onSearchUser(email)
if (!user) {
// TODO handle this better via 404 status code
return
}
await this.props.onAddUser({
teamId,
userId: user.id,
})
this.setState({email: '', user: undefined})
// TODO handle failures
}
render() {
return (
<form onSubmit={this.handleAddUser}>
<input
onChange={this.handleChangeEmail}
placeholder='Email'
type='email'
value={this.state.email}
/>
<input type='submit' value='Add' />
</form>
)
}
}
export class TeamUserList extends React.PureComponent<ITeamUsersProps> {
async componentDidMount() {
await this.fetchUsersInTeam(this.props.teamId)
}
async componentWillReceiveProps(nextProps: ITeamUsersProps) {
const {teamId} = nextProps
if (teamId !== this.props.teamId) {
this.fetchUsersInTeam(teamId)
}
}
async fetchUsersInTeam(teamId: number) {
if (teamId) {
await this.props.fetchUsersInTeam(teamId)
}
}
render() {
const userKeysByTeamId = this.props.userKeysByTeamId[this.props.teamId]
|| EMPTY_ARRAY
return (
<React.Fragment>
<div className='team-user-list'>
{userKeysByTeamId.map(key => {
const user = this.props.usersByKey[key]
return (
<TeamUser
user={user}
onRemoveUser={this.props.onRemoveUser}
/>
)
})}
</div>
<AddUser
onAddUser={this.props.onAddUser}
onSearchUser={this.props.findUserByEmail}
teamId={this.props.teamId}
/>
</React.Fragment>
)
}
}

View File

@ -0,0 +1,6 @@
export * from './TeamActions'
export * from './TeamConnector'
export * from './TeamList'
export * from './TeamManager'
export * from './TeamReducer'
export * from './TeamUserList'

View File

@ -35,6 +35,15 @@ export interface IAPIDef {
}
}
}
'/users/emails/:email': {
// TODO exposing search by email might be a security concern
'get': {
params: {
email: string
}
response: IUser | undefined
}
}
// TEAM
@ -67,6 +76,7 @@ export interface IAPIDef {
params: {
id: number
}
response: {id: number}
}
}

View File

@ -0,0 +1,2 @@
export type ReadonlyRecord<K extends string | number | symbol, V> =
Readonly<Record<K, V>>

View File

@ -9,4 +9,6 @@ export * from './IUser'
export * from './IUser'
export * from './IUserInTeam'
export * from './IUserTeam'
export * from './ReadonlyRecord'
export * from './URLFormatter'
export * from './indexBy'

View File

@ -36,6 +36,9 @@ describe('passport.promise', () => {
}
return undefined
}
async findUserByEmail(email: string) {
return undefined
}
})()
const authenticator = new Authenticator(userService)

View File

@ -50,7 +50,7 @@ describe('UserService', () => {
await createUser()
const user = await userService.findUserByEmail(username)
expect(user).toBeTruthy()
expect(user!.password).toBe(undefined)
expect(user).not.toHaveProperty('password')
})
})

View File

@ -5,7 +5,7 @@ import {IUserInTeam} from '@rondo/common'
export interface ITeamService {
create(params: {name: string, userId: number}): Promise<Team>
remove(params: {id: number, userId: number}): Promise<void>
remove(params: {id: number, userId: number}): Promise<{id: number}>
update(params: {id: number, name: string, userId: number}): Promise<Team>

View File

@ -30,6 +30,8 @@ export class TeamService extends BaseService implements ITeamService {
await this.getRepository(Team)
.delete({id})
return {id}
}
async update({id, name, userId}: {id: number, name: string, userId: number}) {