From a628082a73d61e0710607f2c38707f077cda814a Mon Sep 17 00:00:00 2001 From: Jerko Steiner Date: Wed, 20 Mar 2019 15:01:15 +0500 Subject: [PATCH] Add packages/client/src/team --- packages/client/src/team/TeamActions.ts | 197 ++++++++++++++++++ .../client/src/team/TeamConnector.test.ts | 24 +++ packages/client/src/team/TeamConnector.ts | 32 +++ packages/client/src/team/TeamList.tsx | 117 +++++++++++ packages/client/src/team/TeamManager.tsx | 62 ++++++ packages/client/src/team/TeamReducer.ts | 125 +++++++++++ packages/client/src/team/TeamUserList.tsx | 143 +++++++++++++ packages/client/src/team/index.ts | 6 + packages/common/src/IAPIDef.ts | 10 + packages/common/src/ReadonlyRecord.ts | 2 + packages/common/src/index.ts | 2 + .../src/middleware/Authenticator.test.ts | 3 + .../server/src/services/UserService.test.ts | 2 +- packages/server/src/team/ITeamService.ts | 2 +- packages/server/src/team/TeamService.ts | 2 + 15 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/team/TeamActions.ts create mode 100644 packages/client/src/team/TeamConnector.test.ts create mode 100644 packages/client/src/team/TeamConnector.ts create mode 100644 packages/client/src/team/TeamList.tsx create mode 100644 packages/client/src/team/TeamManager.tsx create mode 100644 packages/client/src/team/TeamReducer.ts create mode 100644 packages/client/src/team/TeamUserList.tsx create mode 100644 packages/client/src/team/index.ts create mode 100644 packages/common/src/ReadonlyRecord.ts diff --git a/packages/client/src/team/TeamActions.ts b/packages/client/src/team/TeamActions.ts new file mode 100644 index 0000000..6420371 --- /dev/null +++ b/packages/client/src/team/TeamActions.ts @@ -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) {} + + fetchMyTeams = (): IAction => { + return { + payload: this.http.get('/my/teams'), + type: TeamActionKeys.TEAMS, + } + } + + fetchMyTeamsError = (error: Error) + : IErrorAction => { + return { + error, + type: TeamActionKeys.TEAMS_REJECTED, + } + } + + createTeam = (team: {name: string}) + : IAction => { + return { + payload: this.http.post('/teams', team), + type: TeamActionKeys.TEAM_CREATE, + } + } + + createTeamError = (error: Error) + : IErrorAction => { + return { + error, + type: TeamActionKeys.TEAM_CREATE_REJECTED, + } + } + + updateTeam = ({id, name}: {id: number, name: string}) + : IAction => { + return { + payload: this.http.put('/teams/:id', {name}, {id}), + type: TeamActionKeys.TEAM_UPDATE, + } + } + + updateTeamError = (error: Error) + : IErrorAction => { + 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 => { + return { + error, + type: TeamActionKeys.TEAM_REMOVE_REJECTED, + } + } + + addUser( + {userId, teamId, roleId = 1}: { + userId: number, + teamId: number, + roleId: number, + }) + : IAction { + return { + payload: this.http.post('/teams/:teamId/users/:userId', {}, { + userId, + teamId, + }), + type: TeamActionKeys.TEAM_USER_ADD, + } + } + + addUserError = (error: Error) + : IErrorAction => { + 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 => { + 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 => { + return { + error, + type: TeamActionKeys.TEAM_USERS_REJECTED, + } + } + + findUserByEmail = (email: string) + : IAction => { + return { + payload: this.http.get('/users/emails/:email', { + email, + }), + type: TeamActionKeys.TEAM_USER_FIND, + } + } + + findUserByEmailError = (error: Error) + : IErrorAction => { + return { + error, + type: TeamActionKeys.TEAM_USER_FIND_REJECTED, + } + } +} + +export type TeamActionType = ActionTypes diff --git a/packages/client/src/team/TeamConnector.test.ts b/packages/client/src/team/TeamConnector.test.ts new file mode 100644 index 0000000..620d46f --- /dev/null +++ b/packages/client/src/team/TeamConnector.test.ts @@ -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() + 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() + }) + +}) diff --git a/packages/client/src/team/TeamConnector.ts b/packages/client/src/team/TeamConnector.ts new file mode 100644 index 0000000..e5a4bd0 --- /dev/null +++ b/packages/client/src/team/TeamConnector.ts @@ -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 { + constructor(protected readonly teamActions: TeamActions) { + super() + } + + connect(getLocalState: IStateSelector) { + 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, + ) + } +} diff --git a/packages/client/src/team/TeamList.tsx b/packages/client/src/team/TeamList.tsx new file mode 100644 index 0000000..e9aac11 --- /dev/null +++ b/packages/client/src/team/TeamList.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import {ITeam, ReadonlyRecord} from '@rondo/common' + +export interface ITeamListProps { + teamsById: ReadonlyRecord, + teamIds: ReadonlyArray, + onAddTeam: (params: {name: string}) => Promise + onRemoveTeam: (params: {teamId: number}) => Promise + onUpdateTeam: (params: {id: number, name: string}) => Promise + editTeamId: number +} + +export interface ITeamProps { + team: ITeam + editTeamId: number // TODO handle edits via react-router params + onRemoveTeam: (params: {teamId: number}) => Promise + onUpdateTeam: (params: {id: number, name: string}) => Promise +} + +export interface IAddTeamProps { + onAddTeam: (params: {name: string}) => Promise + onUpdateTeam: (params: {id: number, name: string}) => Promise + team?: ITeam +} + +export interface IAddTeamState { + name: string +} + +export class TeamAdd extends React.PureComponent { + constructor(props: IAddTeamProps) { + super(props) + this.state = { + name: '', + } + } + handleChange = (event: React.ChangeEvent) => { + 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 ( +
+ {this.props.team ? 'Edit team' : 'Add team'} + + +
+ ) + } +} + +export class TeamRow extends React.PureComponent { + handleRemove = async () => { + const {onRemoveTeam, team: {id: teamId}} = this.props + await onRemoveTeam({teamId}) + } + render() { + const {team} = this.props + return ( +
+ {team.id} + {this.props.editTeamId !== team.id + ? team.name + : + } + +
+ ) + } +} + +export class TeamList extends React.PureComponent { + render() { + const {editTeamId, teamIds, teamsById} = this.props + + return ( +
+ {teamIds.map(teamId => { + const team = teamsById[teamId] + return ( + + ) + })} + +
+ ) + } +} diff --git a/packages/client/src/team/TeamManager.tsx b/packages/client/src/team/TeamManager.tsx new file mode 100644 index 0000000..f24ac89 --- /dev/null +++ b/packages/client/src/team/TeamManager.tsx @@ -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 + updateTeam: (params: {id: number, name: string}) => Promise + removeTeam: (params: {teamId: number}) => Promise + + addUser: (params: {userId: number, teamId: number}) => Promise + removeUser: (params: {userId: number, teamId: number}) => Promise + fetchMyTeams: () => void + fetchUsersInTeam: () => void + findUserByEmail: (email: string) => Promise + + teamsById: ReadonlyRecord + teamIds: ReadonlyArray + + editTeamId: number + + userKeysByTeamId: ReadonlyRecord> + usersByKey: ReadonlyRecord +} + +export class TeamManager extends React.PureComponent { + 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 ( + + + + + + ) + } +} diff --git a/packages/client/src/team/TeamReducer.ts b/packages/client/src/team/TeamReducer.ts new file mode 100644 index 0000000..5430c06 --- /dev/null +++ b/packages/client/src/team/TeamReducer.ts @@ -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 + readonly teamsById: ReadonlyRecord + + readonly userKeysByTeamId: ReadonlyRecord> + readonly usersByKey: ReadonlyRecord +} + +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) + + 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 +} diff --git a/packages/client/src/team/TeamUserList.tsx b/packages/client/src/team/TeamUserList.tsx new file mode 100644 index 0000000..5889ec4 --- /dev/null +++ b/packages/client/src/team/TeamUserList.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import {IUser, IUserInTeam, ReadonlyRecord} from '@rondo/common' + +const EMPTY_ARRAY: ReadonlyArray = [] + +export interface ITeamUsersProps { + // fetchMyTeams: () => void, + fetchUsersInTeam: (teamId: number) => void + findUserByEmail: (email: string) => Promise + + onAddUser: (params: {userId: number, teamId: number}) => Promise + onRemoveUser: (params: {userId: number, teamId: number}) => Promise + + teamId: number + userKeysByTeamId: ReadonlyRecord> + usersByKey: ReadonlyRecord +} + +export interface ITeamUserProps { + onRemoveUser: (params: {userId: number, teamId: number}) => void + user: IUserInTeam +} + +export interface IAddUserProps { + onAddUser: (params: {userId: number, teamId: number}) => Promise + onSearchUser: (email: string) => Promise + teamId: number +} + +export interface IAddUserState { + email: string + user?: IUser +} + +export class TeamUser extends React.PureComponent { + handleRemoveUser = async () => { + const {onRemoveUser, user} = this.props + await onRemoveUser(user) + } + render() { + const {user} = this.props + // TODO style + return ( +
+ {user.displayName} + +
+ ) + } +} + +export class AddUser extends React.PureComponent { + constructor(props: IAddUserProps) { + super(props) + this.state = { + email: '', + user: undefined, + } + } + handleChangeEmail = (event: React.ChangeEvent) => { + 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 ( +
+ + +
+ ) + } +} + +export class TeamUserList extends React.PureComponent { + 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 ( + +
+ {userKeysByTeamId.map(key => { + const user = this.props.usersByKey[key] + return ( + + ) + })} +
+ + +
+ ) + } +} diff --git a/packages/client/src/team/index.ts b/packages/client/src/team/index.ts new file mode 100644 index 0000000..30528e4 --- /dev/null +++ b/packages/client/src/team/index.ts @@ -0,0 +1,6 @@ +export * from './TeamActions' +export * from './TeamConnector' +export * from './TeamList' +export * from './TeamManager' +export * from './TeamReducer' +export * from './TeamUserList' diff --git a/packages/common/src/IAPIDef.ts b/packages/common/src/IAPIDef.ts index 1866e80..49f2858 100644 --- a/packages/common/src/IAPIDef.ts +++ b/packages/common/src/IAPIDef.ts @@ -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} } } diff --git a/packages/common/src/ReadonlyRecord.ts b/packages/common/src/ReadonlyRecord.ts new file mode 100644 index 0000000..d07ce30 --- /dev/null +++ b/packages/common/src/ReadonlyRecord.ts @@ -0,0 +1,2 @@ +export type ReadonlyRecord = + Readonly> diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4865634..9f2c583 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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' diff --git a/packages/server/src/middleware/Authenticator.test.ts b/packages/server/src/middleware/Authenticator.test.ts index f6d4db9..e0a2557 100644 --- a/packages/server/src/middleware/Authenticator.test.ts +++ b/packages/server/src/middleware/Authenticator.test.ts @@ -36,6 +36,9 @@ describe('passport.promise', () => { } return undefined } + async findUserByEmail(email: string) { + return undefined + } })() const authenticator = new Authenticator(userService) diff --git a/packages/server/src/services/UserService.test.ts b/packages/server/src/services/UserService.test.ts index efecbaf..efdb3c4 100644 --- a/packages/server/src/services/UserService.test.ts +++ b/packages/server/src/services/UserService.test.ts @@ -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') }) }) diff --git a/packages/server/src/team/ITeamService.ts b/packages/server/src/team/ITeamService.ts index 4c70c16..bb0093f 100644 --- a/packages/server/src/team/ITeamService.ts +++ b/packages/server/src/team/ITeamService.ts @@ -5,7 +5,7 @@ import {IUserInTeam} from '@rondo/common' export interface ITeamService { create(params: {name: string, userId: number}): Promise - remove(params: {id: number, userId: number}): Promise + remove(params: {id: number, userId: number}): Promise<{id: number}> update(params: {id: number, name: string, userId: number}): Promise diff --git a/packages/server/src/team/TeamService.ts b/packages/server/src/team/TeamService.ts index 490ecdf..cd00939 100644 --- a/packages/server/src/team/TeamService.ts +++ b/packages/server/src/team/TeamService.ts @@ -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}) {