Add packages/client/src/team
This commit is contained in:
parent
9aff78b7a9
commit
a628082a73
197
packages/client/src/team/TeamActions.ts
Normal file
197
packages/client/src/team/TeamActions.ts
Normal 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>
|
||||||
24
packages/client/src/team/TeamConnector.test.ts
Normal file
24
packages/client/src/team/TeamConnector.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
32
packages/client/src/team/TeamConnector.ts
Normal file
32
packages/client/src/team/TeamConnector.ts
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
117
packages/client/src/team/TeamList.tsx
Normal file
117
packages/client/src/team/TeamList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/client/src/team/TeamManager.tsx
Normal file
62
packages/client/src/team/TeamManager.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
packages/client/src/team/TeamReducer.ts
Normal file
125
packages/client/src/team/TeamReducer.ts
Normal 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
|
||||||
|
}
|
||||||
143
packages/client/src/team/TeamUserList.tsx
Normal file
143
packages/client/src/team/TeamUserList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/client/src/team/index.ts
Normal file
6
packages/client/src/team/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './TeamActions'
|
||||||
|
export * from './TeamConnector'
|
||||||
|
export * from './TeamList'
|
||||||
|
export * from './TeamManager'
|
||||||
|
export * from './TeamReducer'
|
||||||
|
export * from './TeamUserList'
|
||||||
@ -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
|
// TEAM
|
||||||
|
|
||||||
@ -67,6 +76,7 @@ export interface IAPIDef {
|
|||||||
params: {
|
params: {
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
response: {id: number}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
packages/common/src/ReadonlyRecord.ts
Normal file
2
packages/common/src/ReadonlyRecord.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export type ReadonlyRecord<K extends string | number | symbol, V> =
|
||||||
|
Readonly<Record<K, V>>
|
||||||
@ -9,4 +9,6 @@ export * from './IUser'
|
|||||||
export * from './IUser'
|
export * from './IUser'
|
||||||
export * from './IUserInTeam'
|
export * from './IUserInTeam'
|
||||||
export * from './IUserTeam'
|
export * from './IUserTeam'
|
||||||
|
export * from './ReadonlyRecord'
|
||||||
export * from './URLFormatter'
|
export * from './URLFormatter'
|
||||||
|
export * from './indexBy'
|
||||||
|
|||||||
@ -36,6 +36,9 @@ describe('passport.promise', () => {
|
|||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
async findUserByEmail(email: string) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
const authenticator = new Authenticator(userService)
|
const authenticator = new Authenticator(userService)
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,7 @@ describe('UserService', () => {
|
|||||||
await createUser()
|
await createUser()
|
||||||
const user = await userService.findUserByEmail(username)
|
const user = await userService.findUserByEmail(username)
|
||||||
expect(user).toBeTruthy()
|
expect(user).toBeTruthy()
|
||||||
expect(user!.password).toBe(undefined)
|
expect(user).not.toHaveProperty('password')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {IUserInTeam} from '@rondo/common'
|
|||||||
export interface ITeamService {
|
export interface ITeamService {
|
||||||
create(params: {name: string, userId: number}): Promise<Team>
|
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>
|
update(params: {id: number, name: string, userId: number}): Promise<Team>
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export class TeamService extends BaseService implements ITeamService {
|
|||||||
|
|
||||||
await this.getRepository(Team)
|
await this.getRepository(Team)
|
||||||
.delete({id})
|
.delete({id})
|
||||||
|
|
||||||
|
return {id}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update({id, name, userId}: {id: number, name: string, userId: number}) {
|
async update({id, name, userId}: {id: number, name: string, userId: number}) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user