Merge pull request #781 from ZmagoD/zd_SCI_1499

Write single team page in React.js
This commit is contained in:
Zmago Devetak 2017-09-18 15:59:44 +02:00 committed by GitHub
commit 4e8381d9ba
46 changed files with 1696 additions and 205 deletions

View file

@ -92,6 +92,7 @@ group :development, :test do
gem 'rubocop', require: false
gem 'scss_lint', require: false
gem 'starscope', require: false
gem 'bullet'
end
group :test do

View file

@ -105,6 +105,9 @@ GEM
momentjs-rails (>= 2.8.1)
bootstrap_form (2.7.0)
builder (3.2.3)
bullet (5.6.1)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
byebug (9.0.6)
capybara (2.15.1)
addressable
@ -431,6 +434,7 @@ GEM
execjs (>= 0.3.0, < 3)
underscore-rails (1.8.3)
unicode-display_width (1.3.0)
uniform_notifier (1.10.0)
warden (1.2.7)
rack (>= 1.0)
webpacker (2.0)
@ -467,6 +471,7 @@ DEPENDENCIES
bootstrap-select-rails
bootstrap3-datetimepicker-rails (~> 4.15.35)
bootstrap_form
bullet
byebug
capybara
commit_param_routing
@ -543,4 +548,4 @@ RUBY VERSION
ruby 2.4.1p111
BUNDLED WITH
1.15.3
1.15.4

View file

@ -0,0 +1,65 @@
module ClientApi
module Teams
class TeamsController < ApplicationController
include ClientApi::Users::UserTeamsHelper
def index
success_response('/client_api/teams/index',
teams: current_user.teams_data)
end
def details
team_service = ClientApi::TeamsService.new(team_id: params[:team_id],
current_user: current_user)
success_response('/client_api/teams/details',
team_service.team_page_details_data)
rescue ClientApi::CustomTeamError
error_response
end
def change_team
team_service = ClientApi::TeamsService.new(team_id: params[:team_id],
current_user: current_user)
team_service.change_current_team!
success_response('/client_api/teams/index', team_service.teams_data)
rescue ClientApi::CustomTeamError
error_response
end
def update
team_service = ClientApi::TeamsService.new(team_id: params[:team_id],
current_user: current_user,
params: team_params)
team_service.update_team!
success_response('/client_api/teams/update_details',
team_service.single_team_details_data)
rescue ClientApi::CustomTeamError => error
error_response(error.to_s)
end
private
def team_params
params.require(:team).permit(:description, :name)
end
def success_response(template, locals)
respond_to do |format|
format.json do
render template: template,
status: :ok,
locals: locals
end
end
end
def error_response(message = t('client_api.generic_error_message'))
respond_to do |format|
format.json do
render json: { message: message }, status: :unprocessable_entity
end
end
end
end
end
end

View file

@ -1,50 +0,0 @@
module ClientApi
class TeamsController < ApplicationController
include ClientApi::Users::UserTeamsHelper
MissingTeamError = Class.new(StandardError)
def index
success_response
end
def change_team
change_current_team
success_response
rescue MissingTeamError
error_response
end
private
def success_response
respond_to do |format|
format.json do
render template: '/client_api/teams/index',
status: :ok,
locals: teams
end
end
end
def error_response
respond_to do |format|
format.json do
render json: { message: 'Bad boy!' }, status: :bad_request
end
end
end
def teams
{ teams: current_user.teams_data }
end
def change_current_team
team_id = params.fetch(:team_id) { raise MissingTeamError }
unless current_user.teams.pluck(:id).include? team_id
raise MissingTeamError
end
current_user.update_attribute(:current_team_id, team_id)
end
end
end

View file

@ -1,86 +1,67 @@
module ClientApi
module Users
class UserTeamsController < ApplicationController
include NotificationsHelper
include InputSanitizeHelper
include ClientApi::Users::UserTeamsHelper
before_action :find_user_team, only: :leave_team
def leave_team
if user_cant_leave?
unsuccess_response
else
begin
assign_new_team_owner
generate_new_notification
success_response
rescue
unsuccess_response
end
end
ut_service = ClientApi::UserTeamService.new(
user: current_user,
team_id: params[:team],
user_team_id: params[:user_team]
)
ut_service.destroy_user_team_and_assign_new_team_owner!
success_response('/client_api/teams/index', ut_service.teams_data)
rescue ClientApi::CustomUserTeamError
unsuccess_response(t('client_api.user_teams.leave_team_error'))
end
def update_role
ut_service = ClientApi::UserTeamService.new(
user: current_user,
team_id: params[:team],
user_team_id: params[:user_team],
role: params[:role]
)
ut_service.update_role!
success_response('/client_api/teams/team_users',
ut_service.team_users_data)
rescue ClientApi::CustomUserTeamError => error
unsuccess_response(error.to_s)
end
def remove_user
ut_service = ClientApi::UserTeamService.new(
user: current_user,
team_id: params[:team],
user_team_id: params[:user_team]
)
ut_service.destroy_user_team_and_assign_new_team_owner!
success_response('/client_api/teams/team_users',
ut_service.team_users_data)
rescue ClientApi::CustomUserTeamError => error
unsuccess_response(error.to_s)
end
private
def find_user_team
@team = Team.find_by_id(params[:team])
@user_team = UserTeam.where(team: @team, user: current_user).first
end
def user_cant_leave?
return unless @user_team && @team
@user_team.admin? &&
@team.user_teams.where(role: 2).count <= 1
end
def success_response
def success_response(template, locals)
respond_to do |format|
# return a list of teams
format.json do
render template: '/client_api/teams/index',
render template: template,
status: :ok,
locals: {
teams: current_user.teams_data,
flash_message: t('client_api.user_teams.leave_flash',
team: @team.name)
}
locals: locals
end
end
end
def unsuccess_response
def unsuccess_response(message)
respond_to do |format|
format.json do
render json: { message: t(
'client_api.user_teams.leave_team_error'
) },
render json: { message: message },
status: :unprocessable_entity
end
end
end
def assign_new_team_owner
new_owner = @team.user_teams
.where(role: 2)
.where.not(id: @user_team.id)
.first.user
new_owner ||= current_user
reset_user_current_team(@user_team)
@user_team.destroy(new_owner)
end
def reset_user_current_team(user_team)
ids = user_team.user.teams_ids
ids -= [user_team.team.id]
user_team.user.current_team_id = ids.first
user_team.user.save
end
def generate_new_notification
generate_notification(@user_team.user, @user_team.user, @user_team.team,
false, false)
end
end
end
end

View file

@ -1,6 +1,7 @@
// teams
export const SET_CURRENT_TEAM = "SET_CURRENT_TEAM";
export const GET_LIST_OF_TEAMS = "GET_LIST_OF_TEAMS";
export const SET_TEAM_DETAILS = "SET_TEAM_DETAILS";
// activities
export const GLOBAL_ACTIVITIES_DATA = "GLOBAL_ACTIVITIES_DATA";
@ -26,7 +27,10 @@ export const CHANGE_SYSTEM_MESSAGE_NOTIFICATION_EMAIL =
// user teams
export const LEAVE_TEAM = "LEAVE_TEAM";
// modals
export const SHOW_LEAVE_TEAM_MODAL = "SHOW_LEAVE_TEAM_MODAL";
export const UPDATE_TEAM_DESCRIPTION_MODAL = "UPDATE_TEAM_DESCRIPTION_MODAL";
// spinner
export const SPINNER_ON = "SPINNER_ON";

View file

@ -7,7 +7,7 @@ export const BORDER_LIGHT_COLOR = "#e3e3e3";
export const WILD_SAND_COLOR = "#f5f5f5";
export const MYSTIC_COLOR = "#eaeff2";
export const COLOR_CONCRETE = "#f2f2f2";
export const COLOR_MINE_SHAFT = "#333"
export const COLOR_MINE_SHAFT = "#333";
export const COLOR_BLACK = "#000";
export const COLOR_GRAY_LIGHT_YADCF = "#ccc";
export const ICON_GREEN_COLOR = "#8fd13f";
@ -18,3 +18,4 @@ export const SIDEBAR_HOVER_GRAY_COLOR = "#D2D2D2";
export const COLOR_ALTO = "#dddddd";
export const COLOR_GRAY = "#909088";
export const COLOR_ALABASTER = "#fcfcfc";
export const COLOR_APPLE_BLOSSOM = "#a94442";

View file

@ -1 +1,3 @@
export const ENTER_KEY_CODE = 13;
export const TEXT_MAX_LENGTH = 10000;
export const NAME_MAX_LENGTH = 255;

View file

@ -0,0 +1,3 @@
// Settings page
export const SETTINGS_TEAMS_ROUTE = "/settings/teams";
export const SETTINGS_TEAM_ROUTE = "/settings/teams/:id";

View file

@ -1,11 +1,11 @@
import { combineReducers } from "redux";
import {
setCurrentTeam,
getListOfTeams
getListOfTeams,
showLeaveTeamModal,
} from "../shared/reducers/TeamReducers";
import { globalActivities } from "../shared/reducers/ActivitiesReducers";
import { currentUser } from "../shared/reducers/UsersReducer";
import { showLeaveTeamModal } from "../shared/reducers/LeaveTeamReducer";
export default combineReducers({
current_team: setCurrentTeam,

View file

@ -13,7 +13,9 @@ export const SETTINGS_ACCOUNT_PREFERENCES_PATH =
// teams
export const TEAMS_PATH = "/client_api/teams";
export const CHANGE_TEAM_PATH = "/client_api/change_team";
export const CHANGE_TEAM_PATH = "/client_api/teams/change_team";
export const TEAM_DETAILS_PATH = "/client_api/teams/:team_id/details";
export const TEAM_UPDATE_PATH = "/client_api/teams/update";
// search
export const SEARCH_PATH = "/search";
@ -49,6 +51,8 @@ export const CONTACT_US_LINK =
// user teams
export const LEAVE_TEAM_PATH = "/client_api/users/leave_team";
export const UPDATE_USER_TEAM_ROLE_PATH = "/client_api/users/update_role";
export const REMOVE_USER_FROM_TEAM_PATH = "/client_api/users/remove_user";
// settings
export const SETTINGS_ACCOUNT_PROFILE = "/settings/account/profile";

View file

@ -7,6 +7,9 @@ export default {
edit: "Edit",
loading: "Loading ..."
},
error_messages: {
text_too_long: "is too long (maximum is {max_length} characters)"
},
navbar: {
page_title: "sciNote",
home_label: "Home",
@ -22,15 +25,6 @@ export default {
in_team: "You are member of {num} team",
in_teams: "You are member of {num} team",
leave_team: "Leave team",
leave_team_modal: {
title: "Leave team {teamName}",
subtitle: "Are you sure you wish to leave team My projects? This action is irreversible.",
warnings: "Leaving team has following consequences:",
warning_message_one: "you will lose access to all content belonging to the team (including projects, tasks, protocols and activities);",
warning_message_two: "all projects in the team where you were the sole <b>Owner</b> will receive a new owner from the team administrators;",
warning_message_three: "all repository protocols in the team belonging to you will be reassigned onto a new owner from team administrators.",
leave_team: "Leave"
},
account: "Account",
team: "Team",
avatar: "Avatar",
@ -66,7 +60,48 @@ export default {
show_in_scinote: "Show in sciNote",
notify_me_via_email: "Notify me via email",
no: "No",
yes: "Yes"
yes: "Yes",
leave_team_modal: {
title: "Leave team {teamName}",
subtitle: "Are you sure you wish to leave team My projects? This action is irreversible.",
warnings: "Leaving team has following consequences:",
warning_message_one: "you will lose access to all content belonging to the team (including projects, tasks, protocols and activities);",
warning_message_two: "all projects in the team where you were the sole <b>Owner</b> will receive a new owner from the team administrators;",
warning_message_three: "all repository protocols in the team belonging to you will be reassigned onto a new owner from team administrators.",
leave_team: "Leave"
},
remove_user_modal: {
title: "Remove user {user} from team {team}",
subtitle: "Are you sure you wish to remove user {user} from team {team}?",
warnings: "Removing user from team has following consequences:",
warning_message_one: "user will lose access to all content belonging to the team (including projects, tasks, protocols and activities);",
warning_message_two: "all projects in the team where user was the sole <b>Owner</b> will be reassigned onto you as a new owner;",
warning_message_three: "all repository protocols in the team belonging to user will be reassigned onto you.",
remove_user: "Remove user"
},
update_team_description_modal: {
title: "Edit team description",
label: "Description"
},
update_team_name_modal: {
title: "Edit team name",
label: "Name"
},
single_team: {
created_on: "Created on: <strong>{created_at}</strong>",
created_by: "Created by: <strong>{created_by}</strong>",
space_usage: "Space usage: <strong>{space_usage}</strong>",
no_description: "<i>No description</i>",
members_panel_title: "Team members",
add_members: "Add team members",
actions: {
user_role: "User role",
guest: "Guest",
normal_user: "Normal user",
administrator: "Administrator",
remove_user: "Remove"
}
}
},
activities: {
modal_title: "Activities",

View file

@ -1,8 +0,0 @@
import { SHOW_LEAVE_TEAM_MODAL } from "../../app/action_types";
export function leaveTeamModalShow(show = false, id = 0, teamName = "") {
return {
payload: { show, id, teamName },
type: SHOW_LEAVE_TEAM_MODAL
};
}

View file

@ -1,7 +1,18 @@
import axios from "../../app/axios";
import _ from "lodash";
import { TEAMS_PATH, CHANGE_TEAM_PATH } from "../../app/routes";
import { GET_LIST_OF_TEAMS, SET_CURRENT_TEAM } from "../../app/action_types";
import {
GET_LIST_OF_TEAMS,
SET_CURRENT_TEAM,
SHOW_LEAVE_TEAM_MODAL
} from "../../app/action_types";
export function leaveTeamModalShow(show = false, team = {}) {
return {
payload: { team, show },
type: SHOW_LEAVE_TEAM_MODAL
};
}
export function addTeamsData(data) {
return {
@ -33,10 +44,10 @@ export function getTeamsList() {
};
}
export function changeTeam(teamId) {
export function changeTeam(team_id) {
return dispatch => {
axios
.post(CHANGE_TEAM_PATH, { teamId }, { withCredentials: true })
.post(CHANGE_TEAM_PATH, { team_id }, { withCredentials: true })
.then(response => {
const teams = response.data.teams.collection;
dispatch(addTeamsData(teams));

View file

@ -6,8 +6,7 @@ import { connect } from "react-redux";
import axios from "../../../app/axios";
import { LEAVE_TEAM_PATH } from "../../../app/routes";
import { leaveTeamModalShow } from "../../actions/LeaveTeamActions";
import { addTeamsData, setCurrentTeam } from "../../actions/TeamsActions";
import { addTeamsData, setCurrentTeam, leaveTeamModalShow } from "../../actions/TeamsActions";
class LeaveTeamModal extends Component {
constructor(props) {
@ -21,13 +20,13 @@ class LeaveTeamModal extends Component {
}
leaveTeam() {
const teamUrl = `${LEAVE_TEAM_PATH}?team=${this.props.teamId}`;
const teamUrl = `${LEAVE_TEAM_PATH}?team=${this.props.team
.id}&user_team=${this.props.team.user_team_id}`;
axios
.delete(teamUrl, {
withCredentials: true
})
.then(response => {
console.log(response);
const teams = response.data.teams.collection;
this.props.addTeamsData(teams);
const currentTeam = _.find(teams, team => team.current_team);
@ -46,7 +45,7 @@ class LeaveTeamModal extends Component {
<Modal.Title>
<FormattedMessage
id="settings_page.leave_team_modal.title"
values={{ teamName: this.props.teamName }}
values={{ teamName: this.props.team.name }}
/>
</Modal.Title>
</Modal.Header>
@ -85,15 +84,19 @@ class LeaveTeamModal extends Component {
LeaveTeamModal.propTypes = {
showModal: bool.isRequired,
teamId: number.isRequired,
teamName: string.isRequired,
team: PropTypes.shape({
id: number.isRequired,
name: string.isRequired,
user_team_id: number.isRequired
}).isRequired,
addTeamsData: func.isRequired,
leaveTeamModalShow: func.isRequired
leaveTeamModalShow: func.isRequired,
setCurrentTeam: func.isRequired
};
const mapStateToProps = ({ showLeaveTeamModal }) => ({
showModal: showLeaveTeamModal.show,
teamId: showLeaveTeamModal.id,
teamName: showLeaveTeamModal.teamName
team: showLeaveTeamModal.team
});
export default connect(mapStateToProps, {

View file

@ -1,11 +0,0 @@
import { SHOW_LEAVE_TEAM_MODAL } from "../../app/action_types";
export function showLeaveTeamModal(
state = { show: false, id: 0, teamName: "" },
action
) {
if (action.type === SHOW_LEAVE_TEAM_MODAL) {
return { ...state, ...action.payload };
}
return state;
}

View file

@ -1,7 +1,13 @@
import { SET_CURRENT_TEAM, GET_LIST_OF_TEAMS } from "../../app/action_types";
import {
SET_CURRENT_TEAM,
GET_LIST_OF_TEAMS,
SHOW_LEAVE_TEAM_MODAL
} from "../../app/action_types";
const initialState = { name: "", id: 0, current_team: true };
export const setCurrentTeam = (state = initialState, action) => {
export const setCurrentTeam = (
state = { name: "", id: 0, current_team: true },
action
) => {
if (action.type === SET_CURRENT_TEAM) {
return Object.assign({}, state, action.team);
}
@ -17,3 +23,13 @@ export const getListOfTeams = (state = { collection: [] }, action) => {
}
return state;
};
export const showLeaveTeamModal = (
state = { show: false, team: { id: 0, name: "", user_team_id: 0 } },
action
) => {
if (action.type === SHOW_LEAVE_TEAM_MODAL) {
return { ...state, ...action.payload };
}
return state;
};

View file

@ -5,6 +5,11 @@ import { Nav, NavItem } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import Navigation from "../../../shared/navigation";
import {
SETTINGS_TEAMS_ROUTE,
SETTINGS_TEAM_ROUTE
} from "../../../app/dom_routes";
import {
ROOT_PATH,
SETTINGS_PATH,
@ -16,8 +21,9 @@ import {
} from "../../../app/routes";
import NotFound from "../../../shared/404/NotFound";
import SettingsAccount from ".././components/account/SettingsAccount";
import SettingsTeams from ".././components/team/SettingsTeams";
import SettingsAccount from "./account/SettingsAccount";
import SettingsTeams from "./teams/SettingsTeams";
import SettingsTeamPageContainer from "./team/SettingsTeamPageContainer";
export default class MainNav extends Component {
constructor(props) {
@ -64,8 +70,11 @@ export default class MainNav extends Component {
path={SETTINGS_PATH}
render={() => <Redirect to={SETTINGS_ACCOUNT_PROFILE_PATH} />}
/>
<Route path={SETTINGS_ACCOUNT_PATH} component={SettingsAccount} />
<Route path={SETTINGS_TEAMS_PATH} component={SettingsTeams} />
<Route
path={SETTINGS_TEAM_ROUTE}
component={SettingsTeamPageContainer}
/>
<Route path={SETTINGS_TEAMS_ROUTE} component={SettingsTeams} />
<Route component={NotFound} />
</Switch>
</div>

View file

@ -0,0 +1,237 @@
import React, { Component } from "react";
import ReactRouterPropTypes from "react-router-prop-types";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { Row, Col, Glyphicon, Well } from "react-bootstrap";
import { FormattedHTMLMessage, FormattedMessage } from "react-intl";
import moment from "moment";
import prettysize from "prettysize";
import axios from "../../../../app/axios";
import { TEAM_DETAILS_PATH, SETTINGS_TEAMS } from "../../../../app/routes";
import { BORDER_LIGHT_COLOR } from "../../../../app/constants/colors";
import TeamsMembers from "./components/TeamsMembers";
import UpdateTeamDescriptionModal from "./components/UpdateTeamDescriptionModal";
import UpdateTeamNameModal from "./components/UpdateTeamNameModal";
const Wrapper = styled.div`
background: white;
box-sizing: border-box;
border: 1px solid ${BORDER_LIGHT_COLOR};
border-top: none;
margin: 0;
padding: 16px 15px 50px 15px;
`;
const TabTitle = styled.div`padding: 15px;`;
const BadgeWrapper = styled.div`
font-size: 1.4em;
float: left;
padding: 6px 10px;
background-color: #37a0d9;
color: #fff;
`;
const StyledWell = styled(Well)`
padding: 9px;
& > span {
padding-left: 5px;
}`;
const StyledDescriptionWell = styled(Well)`
padding: 9px;
& > span {
padding-left: 5px;
}
&:hover {
text-decoration: underline;
cursor: pointer;
}
`;
const StyledH3 = styled.h3`
&:hover {
text-decoration: underline;
cursor: pointer;
}
`;
const StyledOl = styled.ol`padding: 15px;`;
class SettingsTeamPageContainer extends Component {
constructor(props) {
super(props);
this.state = {
showDescriptionModal: false,
showNameModal: false,
users: [],
team: {
id: 0,
name: "",
description: "",
created_by: "",
created_at: "",
space_taken: 0
}
};
this.showDescriptionModal = this.showDescriptionModal.bind(this);
this.hideDescriptionModalCallback = this.hideDescriptionModalCallback.bind(
this
);
this.updateTeamCallback = this.updateTeamCallback.bind(this);
this.updateUsersCallback = this.updateUsersCallback.bind(this);
this.showNameModal = this.showNameModal.bind(this);
this.hideNameModalCallback = this.hideNameModalCallback.bind(this);
this.renderEditNameModel = this.renderEditNameModel.bind(this);
}
componentDidMount() {
const { id } = this.props.match.params;
const path = TEAM_DETAILS_PATH.replace(":team_id", id);
axios.get(path).then(response => {
const { team, users } = response.data.team_details;
this.setState({ users, team });
});
}
showDescriptionModal() {
this.setState({ showDescriptionModal: true });
}
hideDescriptionModalCallback() {
this.setState({ showDescriptionModal: false });
}
showNameModal() {
this.setState({ showNameModal: true });
}
hideNameModalCallback() {
this.setState({ showNameModal: false });
}
updateTeamCallback(team) {
this.setState({ team });
}
updateUsersCallback(users) {
this.setState({ users });
}
renderDescription() {
if (this.state.team.description) {
return this.state.team.description;
}
return (
<FormattedHTMLMessage id="settings_page.single_team.no_description" />
);
}
renderEditNameModel() {
if (this.state.showNameModal) {
return(
<UpdateTeamNameModal
showModal={this.state.showNameModal}
hideModal={this.hideNameModalCallback}
team={this.state.team}
updateTeamCallback={this.updateTeamCallback}
/>
);
}
}
render() {
return (
<Wrapper>
<StyledOl className="breadcrumb">
<li>
<Link to={SETTINGS_TEAMS}>
<FormattedMessage id="settings_page.all_teams" />
</Link>
</li>
<li className="active">
{this.state.team.name}
</li>
</StyledOl>
<TabTitle>
<StyledH3 onClick={this.showNameModal}>
{this.state.team.name}
</StyledH3>
</TabTitle>
<Row>
<Col xs={6} sm={3}>
<BadgeWrapper>
<Glyphicon glyph="calendar" />
</BadgeWrapper>
<StyledWell>
<FormattedHTMLMessage
id="settings_page.single_team.created_on"
values={{
created_at: moment(this.state.team.created_at).format(
"DD.MM.YYYY"
)
}}
/>
</StyledWell>
</Col>
<Col xs={10} sm={5}>
<BadgeWrapper>
<Glyphicon glyph="user" />
</BadgeWrapper>
<StyledWell>
<FormattedHTMLMessage
id="settings_page.single_team.created_by"
values={{ created_by: this.state.team.created_by }}
/>
</StyledWell>
</Col>
<Col xs={8} sm={4}>
<BadgeWrapper>
<Glyphicon glyph="hdd" />
</BadgeWrapper>
<StyledWell>
<FormattedHTMLMessage
id="settings_page.single_team.space_usage"
values={{
space_usage: prettysize(this.state.team.space_taken)
}}
/>
</StyledWell>
</Col>
</Row>
<Row>
<Col sm={12} onClick={this.showDescriptionModal}>
<BadgeWrapper>
<Glyphicon glyph="info-sign" />
</BadgeWrapper>
<StyledDescriptionWell>
<span>
{this.renderDescription()}
</span>
</StyledDescriptionWell>
</Col>
</Row>
<TeamsMembers
members={this.state.users}
updateUsersCallback={this.updateUsersCallback}
team={this.state.team}
/>
<UpdateTeamDescriptionModal
showModal={this.state.showDescriptionModal}
hideModal={this.hideDescriptionModalCallback}
team={this.state.team}
updateTeamCallback={this.updateTeamCallback}
/>
{this.renderEditNameModel()}
</Wrapper>
);
}
}
SettingsTeamPageContainer.PropTypes = {
match: ReactRouterPropTypes.match.isRequired
};
export default SettingsTeamPageContainer;

View file

@ -1,2 +0,0 @@
import { LEAVE_TEAM_PATH } from '../../../../../app/routes'

View file

@ -0,0 +1,99 @@
import React, { Component } from "react";
import { bool, number, string, func, shape } from "prop-types";
import { Modal, Button, Alert, Glyphicon } from "react-bootstrap";
import { FormattedMessage, FormattedHTMLMessage } from "react-intl";
import axios from "../../../../../app/axios";
import { REMOVE_USER_FROM_TEAM_PATH } from "../../../../../app/routes";
class RemoveUserModal extends Component {
constructor(props) {
super(props);
this.onCloseModal = this.onCloseModal.bind(this);
this.removeUser = this.removeUser.bind(this);
}
onCloseModal() {
this.props.hideModal();
}
removeUser() {
const { team_id, team_user_id } = this.props.userToRemove;
axios({
method: "DELETE",
url: REMOVE_USER_FROM_TEAM_PATH,
withCredentials: true,
data: {
team: team_id,
user_team: team_user_id
}
})
.then(response => {
this.props.updateUsersCallback(response.data.team_users);
this.props.hideModal();
})
.catch(error => console.log(error));
}
render() {
const { teamName, userName } = this.props.userToRemove;
return (
<Modal show={this.props.showModal} onHide={this.onCloseModal}>
<Modal.Header closeButton>
<Modal.Title>
<FormattedMessage
id="settings_page.remove_user_modal.title"
values={{ user: userName, team: teamName }}
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
<FormattedMessage
id="settings_page.remove_user_modal.subtitle"
values={{ user: userName, team: teamName }}
/>
</p>
<Alert bsStyle="danger">
<Glyphicon glyph="exclamation-sign" />&nbsp;
<FormattedMessage id="settings_page.remove_user_modal.warnings" />
<ul>
<li>
<FormattedMessage id="settings_page.remove_user_modal.warning_message_one" />
</li>
<li>
<FormattedHTMLMessage id="settings_page.remove_user_modal.warning_message_two" />
</li>
<li>
<FormattedMessage id="settings_page.remove_user_modal.warning_message_three" />
</li>
</ul>
</Alert>
</Modal.Body>
<Modal.Footer>
<Button onClick={this.onCloseModal}>
<FormattedMessage id="general.close" />
</Button>
<Button bsStyle="success" onClick={this.removeUser}>
<FormattedMessage id="settings_page.remove_user_modal.remove_user" />
</Button>
</Modal.Footer>
</Modal>
);
}
}
RemoveUserModal.propTypes = {
showModal: bool.isRequired,
hideModal: func.isRequired,
userToRemove: shape({
userName: string.isRequired,
team_user_id: number.isRequired,
teamName: string.isRequired,
team_id: number.isRequired
}).isRequired,
updateUsersCallback: func.isRequired
};
export default RemoveUserModal;

View file

@ -0,0 +1,212 @@
import React, { Component } from "react";
import PropTypes, { number, func, string, bool } from "prop-types";
import {
Panel,
Button,
Glyphicon,
DropdownButton,
MenuItem
} from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import axios from "../../../../../app/axios";
import RemoveUserModal from "./RemoveUserModal";
import DataTable from "../../../../../shared/data_table";
import { UPDATE_USER_TEAM_ROLE_PATH } from "../../../../../app/routes";
const initalUserToRemove = {
userName: "",
team_user_id: 0,
teamName: "",
team_id: 0
};
class TeamsMembers extends Component {
constructor(params) {
super(params);
this.state = { showModal: false, userToRemove: initalUserToRemove };
this.memberAction = this.memberAction.bind(this);
this.hideModal = this.hideModal.bind(this);
}
currentRole(memberRole, role) {
return memberRole === role ? <Glyphicon glyph="ok" /> : " ";
}
updateRole(userTeamId, role) {
axios
.put(UPDATE_USER_TEAM_ROLE_PATH, {
team: this.props.team.id,
user_team: userTeamId,
role
})
.then(response => {
this.props.updateUsersCallback(response.data.team_users);
})
.catch(error => console.log(error));
}
hideModal() {
this.setState({ showModal: false, userToRemove: initalUserToRemove });
}
userToRemove(userToRemove) {
this.setState({ showModal: true, userToRemove });
}
memberAction(data, row) {
return (
<DropdownButton
bsStyle="default"
disabled={data.disable}
title={
<span>
<Glyphicon glyph="cog" />
</span>
}
id="actions-dropdown"
>
<MenuItem header>
<FormattedMessage id="settings_page.single_team.actions.user_role" />
</MenuItem>
<MenuItem
onSelect={() => {
// 0 => Guest
this.updateRole(data.team_user_id, 0);
}}
>
{this.currentRole(data.current_role, "Guest")}
<FormattedMessage id="settings_page.single_team.actions.guest" />
</MenuItem>
<MenuItem
onSelect={() => {
// 1 => Normal user
this.updateRole(data.team_user_id, 1);
}}
>
{this.currentRole(data.current_role, "Normal user")}
<FormattedMessage id="settings_page.single_team.actions.normal_user" />
</MenuItem>
<MenuItem
onSelect={() => {
// 2 => Administrator
this.updateRole(data.team_user_id, 2);
}}
>
{this.currentRole(data.current_role, "Administrator")}
<FormattedMessage id="settings_page.single_team.actions.administrator" />
</MenuItem>
<MenuItem divider />
<MenuItem
onClick={() => {
this.userToRemove({
userName: row.name,
team_user_id: data.team_user_id,
teamName: this.props.team.name,
team_id: this.props.team.id
});
}}
>
<FormattedMessage id="settings_page.single_team.actions.remove_user" />
</MenuItem>
</DropdownButton>
);
}
render() {
const columns = [
{
id: 1,
name: "Name",
isKey: false,
textId: "name",
position: 0,
dataSort: true
},
{
id: 2,
name: "Email",
isKey: true,
textId: "email",
position: 1,
dataSort: true
},
{
id: 3,
name: "Role",
isKey: false,
textId: "role",
position: 2,
dataSort: true
},
{
id: 4,
name: "Joined on",
isKey: false,
textId: "created_at",
position: 3
},
{
id: 5,
name: "Status",
isKey: false,
textId: "status",
position: 3
},
{
id: 6,
name: "Actions",
isKey: false,
textId: "actions",
columnClassName: "react-bootstrap-table-dropdown-fix",
dataFormat: this.memberAction,
position: 3
}
];
return (
<Panel
header={
<FormattedMessage id="settings_page.single_team.members_panel_title" />
}
>
<Button>
<Glyphicon glyph="plus" />
<FormattedMessage id="settings_page.single_team.add_members" />
</Button>
<DataTable data={this.props.members} columns={columns} />
<RemoveUserModal
showModal={this.state.showModal}
hideModal={this.hideModal}
updateUsersCallback={this.props.updateUsersCallback}
userToRemove={this.state.userToRemove}
/>
</Panel>
);
}
}
TeamsMembers.propTypes = {
updateUsersCallback: func.isRequired,
team: PropTypes.shape({
id: number.isRequired,
name: string.isRequired
}).isRequired,
members: PropTypes.arrayOf(
PropTypes.shape({
id: number.isRequired,
name: string.isRequired,
email: string.isRequired,
role: string.isRequired,
created_at: string.isRequired,
status: string.isRequired,
actions: PropTypes.shape({
current_role: string.isRequired,
team_user_id: number.isRequired,
disable: bool.isRequired
})
}).isRequired
).isRequired
};
export default TeamsMembers;

View file

@ -0,0 +1,130 @@
import React, { Component } from "react";
import PropTypes, { bool, number, string, func } from "prop-types";
import {
Modal,
Button,
FormGroup,
ControlLabel,
FormControl,
HelpBlock
} from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import _ from "lodash";
import styled from "styled-components";
import axios from "../../../../../app/axios";
import { TEXT_MAX_LENGTH } from "../../../../../app/constants/numeric";
import { TEAM_UPDATE_PATH } from "../../../../../app/routes";
import { COLOR_APPLE_BLOSSOM } from "../../../../../app/constants/colors";
const StyledHelpBlock = styled(HelpBlock)`
color: ${COLOR_APPLE_BLOSSOM}
`;
class UpdateTeamDescriptionModal extends Component {
constructor(props) {
super(props);
this.state = { errorMessage: "", description: "" };
this.onCloseModal = this.onCloseModal.bind(this);
this.updateDescription = this.updateDescription.bind(this);
this.handleDescription = this.handleDescription.bind(this);
this.getValidationState = this.getValidationState.bind(this);
}
onCloseModal() {
this.setState({ errorMessage: "", description: "" });
this.props.hideModal();
}
getValidationState() {
return this.state.errorMessage.length > 0 ? "error" : null;
}
handleDescription(el) {
const { value } = el.target;
if (value.length > TEXT_MAX_LENGTH) {
this.setState({
errorMessage: (
<FormattedMessage
id="error_messages.text_too_long"
values={{ max_length: TEXT_MAX_LENGTH }}
/>
)
});
} else {
this.setState({ errorMessage: "", description: value });
}
}
updateDescription() {
axios({
method: "post",
url: TEAM_UPDATE_PATH,
withCredentials: true,
data: {
team_id: this.props.team.id,
team: { description: this.state.description }
}
})
.then(response => {
this.props.updateTeamCallback(response.data.team);
this.onCloseModal();
})
.catch(error => this.setState({ errorMessage: error.message }));
}
render() {
return (
<Modal show={this.props.showModal} onHide={this.onCloseModal}>
<Modal.Header closeButton>
<Modal.Title>
<FormattedMessage id="settings_page.update_team_description_modal.title" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<FormGroup
controlId="teamDescription"
validationState={this.getValidationState()}
>
<ControlLabel>
<FormattedMessage id="settings_page.update_team_description_modal.label" />
</ControlLabel>
<FormControl
componentClass="textarea"
defaultValue={this.props.team.description}
onChange={this.handleDescription}
/>
<FormControl.Feedback />
<StyledHelpBlock>
{this.state.errorMessage}
</StyledHelpBlock>
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button onClick={this.onCloseModal}>
<FormattedMessage id="general.close" />
</Button>
<Button
bsStyle="success"
onClick={this.updateDescription}
disabled={!_.isEmpty(this.state.errorMessage)}
>
<FormattedMessage id="general.update" />
</Button>
</Modal.Footer>
</Modal>
);
}
}
UpdateTeamDescriptionModal.propTypes = {
showModal: bool.isRequired,
hideModal: func.isRequired,
team: PropTypes.shape({
id: number.isRequired,
description: string
}).isRequired,
updateTeamCallback: func.isRequired
};
export default UpdateTeamDescriptionModal;

View file

@ -0,0 +1,130 @@
import React, { Component } from "react";
import PropTypes, { bool, number, string, func } from "prop-types";
import {
Modal,
Button,
FormGroup,
ControlLabel,
FormControl,
HelpBlock
} from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import _ from "lodash";
import styled from "styled-components";
import axios from "../../../../../app/axios";
import { NAME_MAX_LENGTH } from "../../../../../app/constants/numeric";
import { TEAM_UPDATE_PATH } from "../../../../../app/routes";
import { COLOR_APPLE_BLOSSOM } from "../../../../../app/constants/colors";
const StyledHelpBlock = styled(HelpBlock)`
color: ${COLOR_APPLE_BLOSSOM}
`;
class UpdateTeamNameModal extends Component {
constructor(props) {
super(props);
this.state = { errorMessage: "", name: props.team.name };
this.onCloseModal = this.onCloseModal.bind(this);
this.updateName = this.updateName.bind(this);
this.handleName = this.handleName.bind(this);
this.getValidationState = this.getValidationState.bind(this);
}
onCloseModal() {
this.setState({ errorMessage: "", name: "" });
this.props.hideModal();
}
getValidationState() {
return this.state.errorMessage.length > 0 ? "error" : null;
}
handleName(el) {
const { value } = el.target;
if (value.length > NAME_MAX_LENGTH) {
this.setState({
errorMessage: (
<FormattedMessage
id="error_messages.text_too_long"
values={{ max_length: NAME_MAX_LENGTH }}
/>
)
});
} else {
this.setState({ errorMessage: "", name: value });
}
}
updateName() {
axios({
method: "post",
url: TEAM_UPDATE_PATH,
withCredentials: true,
data: {
team_id: this.props.team.id,
team: { name: this.state.name }
}
})
.then(response => {
this.props.updateTeamCallback(response.data.team);
this.onCloseModal();
})
.catch(error => this.setState({ errorMessage: error.message }));
}
render() {
return (
<Modal show={this.props.showModal} onHide={this.onCloseModal}>
<Modal.Header closeButton>
<Modal.Title>
<FormattedMessage id="settings_page.update_team_name_modal.title" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<FormGroup
controlId="teamName"
validationState={this.getValidationState()}
>
<ControlLabel>
<FormattedMessage id="settings_page.update_team_name_modal.label" />
</ControlLabel>
<FormControl
type="text"
onChange={this.handleName}
value={this.state.name}
/>
<FormControl.Feedback />
<StyledHelpBlock>
{this.state.errorMessage}
</StyledHelpBlock>
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button onClick={this.onCloseModal}>
<FormattedMessage id="general.close" />
</Button>
<Button
bsStyle="success"
onClick={this.updateName}
disabled={!_.isEmpty(this.state.errorMessage)}
>
<FormattedMessage id="general.update" />
</Button>
</Modal.Footer>
</Modal>
);
}
}
UpdateTeamNameModal.propTypes = {
showModal: bool.isRequired,
hideModal: func.isRequired,
team: PropTypes.shape({
id: number.isRequired,
name: string
}).isRequired,
updateTeamCallback: func.isRequired
};
export default UpdateTeamNameModal;

View file

@ -2,10 +2,11 @@ import React, { Component } from "react";
import PropTypes, { func, number, string, bool } from "prop-types";
import { connect } from "react-redux";
import { Button } from "react-bootstrap";
import _ from "lodash";
import { Link } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { leaveTeamModalShow } from "../../../../../shared/actions/LeaveTeamActions";
import { leaveTeamModalShow } from "../../../../../shared/actions/TeamsActions";
import DataTable from "../../../../../shared/data_table";
import { SETTINGS_TEAMS_ROUTE } from "../../../../../app/dom_routes";
class TeamsDataTable extends Component {
constructor(props) {
@ -13,18 +14,17 @@ class TeamsDataTable extends Component {
this.leaveTeamModal = this.leaveTeamModal.bind(this);
this.leaveTeamButton = this.leaveTeamButton.bind(this);
this.linkToTeam = this.linkToTeam.bind(this);
}
leaveTeamModal(e, id) {
const team = _.find(this.props.teams, el => el.id === id);
this.props.leaveTeamModalShow(true, id, team.name);
leaveTeamModal(e, team) {
this.props.leaveTeamModalShow(true, team);
}
leaveTeamButton(id) {
const team = _.find(this.props.teams, el => el.id === id);
leaveTeamButton(id, team) {
if (team.can_be_leaved) {
return (
<Button onClick={e => this.leaveTeamModal(e, id)}>
<Button onClick={e => this.leaveTeamModal(e, team)}>
<FormattedMessage id="settings_page.leave_team" />
</Button>
);
@ -36,6 +36,14 @@ class TeamsDataTable extends Component {
);
}
linkToTeam(name, row) {
return (
<Link to={`${SETTINGS_TEAMS_ROUTE}/${row.id}`}>
{name}
</Link>
);
}
render() {
const options = {
defaultSortName: "name",
@ -50,6 +58,7 @@ class TeamsDataTable extends Component {
name: "Name",
isKey: false,
textId: "name",
dataFormat: this.linkToTeam,
position: 0,
dataSort: true
},

View file

@ -21,3 +21,11 @@ body {
background-color: $primary-hover-color;
}
}
// // fixes issue with dropdown in datatable
.react-bootstrap-table-dropdown-fix {
overflow: inherit !important;
& .open > .dropdown-menu {
position: relative !important;
}
}

View file

@ -215,7 +215,8 @@ class User < ApplicationRecord
'CASE WHEN teams.id=? THEN true ELSE false END AS current_team, ' \
'CASE WHEN (SELECT COUNT(*) FROM user_teams WHERE ' \
'user_teams.team_id=teams.id AND role=2) >= 2 THEN true ELSE false ' \
'END AS can_be_leaved FROM teams INNER JOIN user_teams ON ' \
'END AS can_be_leaved, user_teams.id AS user_team_id ' \
'FROM teams INNER JOIN user_teams ON ' \
'teams.id=user_teams.team_id WHERE user_teams.user_id=?',
self.current_team_id, self.id]
)

View file

@ -0,0 +1,38 @@
module ClientApi
class TeamsService
def initialize(arg)
team_id = arg.fetch(:team_id) { raise ClientApi::CustomTeamError }
@params = arg.fetch(:params) { false }
@team = Team.find_by_id(team_id)
@user = arg.fetch(:current_user) { raise ClientApi::CustomTeamError }
raise ClientApi::CustomTeamError unless @user.teams.include? @team
end
def change_current_team!
@user.update_attribute(:current_team_id, @team.id)
end
def team_page_details_data
team_users = UserTeam.includes(:user)
.references(:user)
.where(team: @team)
.distinct
{ team: @team, team_users: team_users }
end
def single_team_details_data
{ team: @team }
end
def update_team!
raise ClientApi::CustomTeamError unless @params
return if @team.update_attributes(@params)
raise ClientApi::CustomTeamError, @team.errors.full_messages
end
def teams_data
{ teams: @user.teams_data }
end
end
CustomTeamError = Class.new(StandardError)
end

View file

@ -0,0 +1,87 @@
module ClientApi
class UserTeamService
include NotificationsHelper
include InputSanitizeHelper
def initialize(args)
parsed_args = validate_params(args)
@team = Team.find_by_id(parsed_args.fetch(:team_id))
@user = parsed_args.fetch(:user)
@user_team = UserTeam.find_by_id(parsed_args.fetch(:user_team_id).to_i)
@role = args.fetch(:role) { false }
end
def destroy_user_team_and_assign_new_team_owner!
raise ClientApi::CustomUserTeamError if user_cant_leave?
new_owner = @team.user_teams
.where(role: 2)
.where.not(id: @user_team.id)
.first.user
new_owner ||= @user
reset_user_current_team(@user_team)
@user_team.destroy(new_owner)
generate_new_notification
end
def update_role!
raise ClientApi::CustomUserTeamError if user_cant_leave?
unless @role
raise ClientApi::CustomUserTeamError,
I18n.t('client_api.generic_error_message')
end
return if @user_team.update_attribute(:role, @role)
raise ClientApi::CustomUserTeamError, @user_team.errors.full_messages
end
def team_users_data
team_users = UserTeam.includes(:user)
.references(:user)
.where(team: @team)
.distinct
{ team_users: team_users }
end
def teams_data
{
teams: @user.teams_data,
flash_message: I18n.t('client_api.user_teams.leave_flash',
team: @team.name)
}
end
private
def reset_user_current_team(user_team)
ids = user_team.user.teams_ids
ids -= [user_team.team.id]
user_team.user.current_team_id = ids.first
user_team.user.save
end
def user_cant_leave?
@user.teams.include?(@team) &&
@user_team.admin? &&
@team.user_teams.where(role: 2).count <= 1
end
def generate_new_notification
user = @user_team.user
generate_notification(user,
user,
@user_team.team,
false,
false)
end
def validate_params(args)
keys = %i(team_id user_team_id user)
raise ClientApi::CustomUserTeamError unless keys.all? { |s| args.key? s }
raise ClientApi::CustomUserTeamError if args.values.any? &:nil?
team_id = args.fetch(:team_id)
user_team_id = args.fetch(:user_team_id)
user = args.fetch(:user)
{ user: user, user_team_id: user_team_id, team_id: team_id }
end
end
CustomUserTeamError = Class.new(StandardError)
end

View file

@ -0,0 +1,23 @@
json.team_details do
json.team do
json.id team.id
json.name team.name
json.created_at team.created_at
json.created_by "#{team.created_by.full_name} (#{team.created_by.email})"
json.space_taken team.space_taken
json.description team.description
end
json.users team_users do |team_user|
json.id team_user.user.id
json.name team_user.user.full_name
json.email team_user.user.email
json.role team_user.role_str
json.created_at I18n.l(team_user.created_at, format: :full_date)
json.status team_user.user.active_status_str
json.actions do
json.current_role team_user.role_str
json.team_user_id team_user.id
json.disable team_user.user == current_user
end
end
end

View file

@ -6,5 +6,6 @@ json.teams do
json.role retrive_role_name(team.fetch('role') { nil })
json.current_team team.fetch('current_team')
json.can_be_leaved team.fetch('can_be_leaved')
json.user_team_id team.fetch('user_team_id')
end
end

View file

@ -0,0 +1,13 @@
json.team_users team_users do |team_user|
json.id team_user.user.id
json.name team_user.user.full_name
json.email team_user.user.email
json.role team_user.role_str
json.created_at I18n.l(team_user.created_at, format: :full_date)
json.status team_user.user.active_status_str
json.actions do
json.current_role team_user.role_str
json.team_user_id team_user.id
json.disable team_user.user == current_user
end
end

View file

@ -0,0 +1,8 @@
json.team do
json.id team.id
json.name team.name
json.created_at team.created_at
json.created_by "#{team.created_by.full_name} (#{team.created_by.email})"
json.space_taken team.space_taken
json.description team.description
end

View file

@ -1,5 +1,11 @@
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
config.after_initialize do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true # raise an error if n+1 query occurs
end
# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true

View file

@ -1821,6 +1821,7 @@ en:
by: 'by'
client_api:
generic_error_message: "Something went wrong! Please try again later."
user_teams:
leave_team_error: "An error occured."
leave_flash: "Successfuly left team %{team}."

View file

@ -17,9 +17,12 @@ Rails.application.routes.draw do
get '/activities', to: 'activities#index'
# teams
get '/teams', to: 'teams#index'
post '/change_team', to: 'teams#change_team'
get '/teams', to: 'teams/teams#index'
namespace :teams do
get '/:team_id/details', to: 'teams#details'
post '/change_team', to: 'teams#change_team'
post '/update', to: 'teams#update'
end
# notifications
get '/recent_notifications', to: 'notifications#recent_notifications'
@ -27,6 +30,9 @@ Rails.application.routes.draw do
get '/current_user_info', to: 'users/users#current_user_info'
namespace :users do
delete '/remove_user', to: 'user_teams#remove_user'
delete '/leave_team', to: 'user_teams#leave_team'
put '/update_role', to: 'user_teams#update_role'
get '/profile_info', to: 'users#profile_info'
get '/statistics_info', to: 'users#statistics_info'
get '/preferences_info', to: 'users#preferences_info'

View file

@ -59,11 +59,14 @@
"postcss-loader": "^2.0.6",
"postcss-smart-import": "^0.7.5",
"precss": "^2.0.0",
"prettysize": "^0.1.0",
"prop-types": "^15.5.10",
"rails-erb-loader": "^5.0.2",
"react": "^15.6.1",
"react-bootstrap": "^0.31.1",
"react-bootstrap-table": "^4.0.0",
"react-bootstrap-timezone-picker": "^1.0.11",
"react-data-grid": "^2.0.2",
"react-dom": "^15.6.1",
"react-intl": "^2.3.0",
"react-intl-redux": "^0.6.0",
@ -71,6 +74,7 @@
"react-redux": "^5.0.5",
"react-router-bootstrap": "^0.24.2",
"react-router-dom": "^4.1.2",
"react-router-prop-types": "^0.0.1",
"react-timezone": "^0.2.0",
"redux": "^3.7.2",
"redux-thunk": "^2.2.0",
@ -80,8 +84,6 @@
"styled-components": "^2.1.1",
"webpack": "^3.2.0",
"webpack-manifest-plugin": "^1.1.2",
"webpack-merge": "^4.1.0",
"react-bootstrap-table": "^4.0.0",
"react-data-grid": "^2.0.2"
"webpack-merge": "^4.1.0"
}
}

View file

@ -0,0 +1,90 @@
require 'rails_helper'
describe ClientApi::Teams::TeamsController, type: :controller do
login_user
before do
@user_one = User.first
@user_two = FactoryGirl.create :user, email: 'sec_user@asdf.com'
@team_one = FactoryGirl.create :team
@team_two = FactoryGirl.create :team, name: 'Team two'
FactoryGirl.create :user_team, team: @team_one, user: @user_one, role: 2
end
describe 'GET #index' do
it 'should return HTTP success response' do
get :index, format: :json
expect(response).to be_success
expect(response).to have_http_status(:ok)
end
end
describe 'POST #change_team' do
it 'should return HTTP success response' do
FactoryGirl.create :user_team, team: @team_two, user: @user_one, role: 2
@user_one.update_attribute(:current_team_id, @team_one.id)
post :change_team, params: { team_id: @team_two.id }, as: :json
expect(response).to have_http_status(:ok)
end
it 'should return HTTP unprocessable_entity response if user not in team' do
@user_one.update_attribute(:current_team_id, @team_one.id)
post :change_team, params: { team_id: @team_two.id }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'should return HTTP success response if same team as current' do
@user_one.update_attribute(:current_team_id, @team_one.id)
post :change_team, params: { team_id: @team_one.id }, as: :json
expect(response).to have_http_status(:ok)
end
end
describe 'GET #details' do
it 'should return HTTP success response' do
FactoryGirl.create :user_team, team: @team_two, user: @user_one, role: 2
@user_one.update_attribute(:current_team_id, @team_one.id)
get :details, params: { team_id: @team_two.id }, as: :json
expect(response).to have_http_status(:ok)
end
it 'should return HTTP unprocessable_entity response if user not in team' do
@user_one.update_attribute(:current_team_id, @team_one.id)
get :details, params: { team_id: @team_two.id }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'should return HTTP unprocessable_entity response if team_id not valid' do
get :details, params: { team_id: 'banana' }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'POST #update' do
let(:user_team) do
create :user_team, team: @team_two, user: @user_one, role: 2
end
it 'should return HTTP success response' do
user_team
post :update,
params: { team_id: @team_two.id,
team: { description: 'My description' } },
as: :json
expect(response).to have_http_status(:ok)
end
it 'should return HTTP unprocessable_entity response iput not valid' do
user_team
post :update,
params: {
team_id: @team_two.id,
team: {
description: "super long: #{'a' * Constants::TEXT_MAX_LENGTH}"
}
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end

View file

@ -1,32 +1,82 @@
require 'rails_helper'
describe ClientApi::Users::UserTeamsController, type: :controller do
login_user
let(:user_one) { User.first }
let(:user_two) { create :user, email: Faker::Internet.email }
let(:team) { create :team }
let(:user_team) { create :user_team, team: team, user: user_one }
describe 'DELETE #leave_team' do
login_user
before do
@user_one = User.first
@user_two = FactoryGirl.create(:user, email: 'sec_user@asdf.com')
@team = FactoryGirl.create :team
FactoryGirl.create :user_team, team: @team, user: @user_one, role: 2
end
it 'Returns HTTP success if user can leave the team' do
FactoryGirl.create :user_team, team: @team, user: @user_two, role: 2
delete :leave_team, params: { team: @team.id }, format: :json
it 'should return HTTP success if user can leave the team' do
create :user_team, team: team, user: user_two
delete :leave_team,
params: { team: team.id, user_team: user_team.id },
format: :json
expect(response).to be_success
expect(response).to have_http_status(200)
expect(response).to have_http_status(:ok)
end
it 'Returns HTTP unprocessable_entity if user can\'t leave the team' do
delete :leave_team, params: { team: @team.id }, format: :json
it 'should return HTTP unprocessable_entity if user can\'t ' \
'leave the team' do
delete :leave_team,
params: { team: team.id, user_team: user_team.id },
format: :json
expect(response).to_not be_success
expect(response).to have_http_status(:unprocessable_entity)
end
it 'Returns HTTP unprocessable_entity if no params given' do
it 'should return HTTP unprocessable_entity if no params given' do
delete :leave_team, format: :json
expect(response).to_not be_success
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'POST #update_role' do
it 'should return HTTP success if user can leave the team' do
user_team_two = create :user_team, team: team, user: user_two, role: 2
post :update_role,
params: { team: team.id,
user_team: user_team_two.id,
role: 'normal_user' },
format: :json
expect(response).to be_success
expect(response).to have_http_status(:ok)
end
it 'should return HTTP unprocessable_entity if user can\'t ' \
'leave the team' do
post :update_role,
params: { team: team.id,
user_team: user_team.id,
role: 'normal_user' },
format: :json
expect(response).to_not be_success
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'DELETE #remove_user' do
it 'should return HTTP success if user can be removed' do
user_team
user_team_two = create :user_team, team: team, user: user_two
post :remove_user,
params: { team: team.id, user_team: user_team_two.id },
format: :json
expect(response).to be_success
expect(response).to have_http_status(:ok)
end
it 'should return HTTP unprocessable_entity if user can\'t ' \
'be removed' do
post :remove_user,
params: { team: team.id,
user_team: user_team.id,
role: 'normal_user' },
format: :json
expect(response).to_not be_success
expect(response).to have_http_status(:unprocessable_entity)
end
end
end

View file

@ -160,14 +160,15 @@ describe User, type: :model do
it 'in a specific format: {id: .., name: .., members: .., role: ' \
'.., current_team: .., can_be_leaved: ..}' do
create :user_team, team: team, user: user_one
user_team = create :user_team, team: team, user: user_one
expected_result = {
id: team.id,
name: team.name,
members: 1,
role: 2,
current_team: true,
can_be_leaved: false
can_be_leaved: false,
user_team_id: user_team.id
}
user_one.teams_data.first.each do |k, v|
@ -176,7 +177,7 @@ describe User, type: :model do
end
it 'should return correct number of team members' do
create :user_team, team: team, user: user_one
user_team = create :user_team, team: team, user: user_one
create :user_team, team: team, user: user_two
expected_result = {
id: team.id,
@ -184,7 +185,8 @@ describe User, type: :model do
members: 2,
role: 2,
current_team: true,
can_be_leaved: true
can_be_leaved: true,
user_team_id: user_team.id
}
user_one.teams_data.first.each do |k, v|

View file

@ -0,0 +1,109 @@
require 'rails_helper'
describe ClientApi::TeamsService do
let(:team_one) { create :team }
let(:user_one) { create :user }
it 'should raise an ClientApi::CustomTeamError if user is not assigned' do
expect {
ClientApi::TeamsService.new(team_id: team_one.id)
}.to raise_error(ClientApi::CustomTeamError)
end
it 'should raise an ClientApi::CustomTeamError if team is not assigned' do
expect {
ClientApi::TeamsService.new(current_user: user_one)
}.to raise_error(ClientApi::CustomTeamError)
end
it 'should raise an ClientApi::CustomTeamError if team is not user team' do
expect {
ClientApi::TeamsService.new(current_user: user_one, team_id: team_one.id)
}.to raise_error(ClientApi::CustomTeamError)
end
describe '#change_current_team!' do
let(:team_two) { create :team, name: 'team two' }
let(:user_two) do
create :user, current_team_id: team_one.id, email: 'user_two@test.com'
end
it 'should change user current team' do
create :user_team, user: user_two, team: team_two
teams_service = ClientApi::TeamsService.new(current_user: user_two,
team_id: team_two.id)
teams_service.change_current_team!
expect(user_two.current_team_id).to eq team_two.id
end
end
describe '#team_page_details_data' do
let(:team_service) do
ClientApi::TeamsService.new(current_user: user_one, team_id: team_one.id)
end
it 'should return team page data' do
user_team = create :user_team, user: user_one, team: team_one
data = team_service.team_page_details_data
expect(data.fetch(:team).name).to eq team_one.name
expect(data.fetch(:team_users).first).to eq user_team
end
end
describe '#teams_data' do
let(:team_service) do
ClientApi::TeamsService.new(current_user: user_one, team_id: team_one.id)
end
it 'should return user teams' do
create :user_team, user: user_one, team: team_one
data = team_service.teams_data.fetch(:teams)
expect(data.first.fetch('name')).to eq team_one.name
end
end
describe '#update_team!' do
let(:team_two) { create :team, name: 'Banana', created_by: user_one }
it 'should raise an error if input invalid' do
create :user_team, user: user_one, team: team_one
team_service = ClientApi::TeamsService.new(
current_user: user_one,
team_id: team_one.id,
params: {
description: "super long: #{'a' * Constants::TEXT_MAX_LENGTH}"
}
)
expect {
team_service.update_team!
}.to raise_error(ClientApi::CustomTeamError)
end
it 'should update the team description if the input is valid' do
create :user_team, user: user_one, team: team_two
desc = 'Banana Team description'
team_service = ClientApi::TeamsService.new(
current_user: user_one,
team_id: team_two.id,
params: {
description: desc
}
)
team_service.update_team!
# load values from db
team_two.reload
expect(team_two.description).to eq desc
end
end
describe '#single_team_details_data' do
let(:team_service) do
ClientApi::TeamsService.new(current_user: user_one, team_id: team_one.id)
end
it 'should return a team object' do
create :user_team, user: user_one, team: team_one
expect(team_service.single_team_details_data.fetch(:team)).to eq team_one
end
end
end

View file

@ -0,0 +1,137 @@
require 'rails_helper'
describe ClientApi::UserTeamService do
let(:team_one) { create :team }
let(:user_one) { create :user, email: Faker::Internet.email }
let(:user_two) { create :user, email: Faker::Internet.email }
let(:user_team) { create :user_team, user: user_one, team: team_one }
it 'should raise ClientApi::CustomUserTeamError if user is not assigned' do
expect {
ClientApi::UserTeamService.new(
team_id: team_one.id,
user_team_id: user_team.id
)
}.to raise_error(ClientApi::CustomUserTeamError)
end
it 'should raise ClientApi::CustomUserTeamError if team is not assigned' do
expect {
ClientApi::UserTeamService.new(user: user_one, user_team_id: user_team.id)
}.to raise_error(ClientApi::CustomUserTeamError)
end
it 'should raise ClientApi::CustomUserTeamError if ' \
'user_team is not assigned' do
expect {
ClientApi::UserTeamService.new(user: user_one, team_id: team_one.id)
}.to raise_error(ClientApi::CustomUserTeamError)
end
describe '#destroy_user_team_and_assign_new_team_owner!' do
it 'should raise ClientApi::CustomUserTeamError if user ' \
'can\'t leave the team' do
ut_service = ClientApi::UserTeamService.new(
team_id: team_one.id,
user_team_id: user_team.id,
user: user_one
)
expect {
ut_service.destroy_user_team_and_assign_new_team_owner!
}.to raise_error(ClientApi::CustomUserTeamError)
end
it 'should destroy the user_team relation' do
create :user_team, team: team_one, user: user_one
new_user_team = create :user_team, team: team_one, user: user_two
ut_service = ClientApi::UserTeamService.new(
team_id: team_one.id,
user_team_id: new_user_team.id,
user: user_one
)
ut_service.destroy_user_team_and_assign_new_team_owner!
expect(team_one.users).to_not include user_two
end
it 'should assign a new owner to the team' do
user_team_one = create :user_team, team: team_one, user: user_one
create :user_team, team: team_one, user: user_two
ut_service = ClientApi::UserTeamService.new(
team_id: team_one.id,
user_team_id: user_team_one.id,
user: user_one
)
ut_service.destroy_user_team_and_assign_new_team_owner!
expect(team_one.users).to include user_two
end
end
describe '#update_role!' do
it 'should raise ClientApi::CustomUserTeamError if no role is set' do
create :user_team, team: team_one, user: user_one
ut_service = ClientApi::UserTeamService.new(
user: user_one,
team_id: team_one.id,
user_team_id: user_team.id
)
expect {
ut_service.update_role!
}.to raise_error(ClientApi::CustomUserTeamError)
end
it 'should update user role' do
create :user_team, team: team_one, user: user_two
user_team = create :user_team, team: team_one, user: user_one
ut_service = ClientApi::UserTeamService.new(
user: user_one,
team_id: team_one.id,
user_team_id: user_team.id,
role: 1
)
ut_service.update_role!
user_team.reload
expect(user_team.role).to eq 'normal_user'
end
it 'should raise ClientApi::CustomUserTeamError if is the last ' \
'admin on the team' do
user_team = create :user_team, team: team_one, user: user_one
ut_service = ClientApi::UserTeamService.new(
user: user_one,
team_id: team_one.id,
user_team_id: user_team.id,
role: 1
)
expect {
ut_service.update_role!
}.to raise_error(ClientApi::CustomUserTeamError)
end
end
describe '#team_users_data' do
it 'should return a hash of team members' do
user_team = create :user_team, team: team_one, user: user_one
ut_service = ClientApi::UserTeamService.new(
user: user_one,
team_id: team_one.id,
user_team_id: user_team.id,
role: 1
)
expect(ut_service.team_users_data.fetch(:team_users)).to include user_team
end
end
describe '#teams_data' do
it 'should return a list of teams where user is a member' do
user_team = create :user_team, team: team_one, user: user_one
ut_service = ClientApi::UserTeamService.new(
user: user_one,
team_id: team_one.id,
user_team_id: user_team.id,
role: 1
)
team_id = ut_service.teams_data[:teams].first.fetch('id')
expect(team_id).to eq team_one.id
end
end
end

View file

@ -16,6 +16,8 @@
require 'capybara/rspec'
require 'simplecov'
require 'faker'
require 'active_record'
require 'bullet'
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
@ -97,4 +99,15 @@ RSpec.configure do |config|
# as the one that triggered the failure.
Kernel.srand config.seed
=end
# Enable bullet gem in tests
if Bullet.enable?
config.before(:each) do
Bullet.start_request
end
config.after(:each) do
Bullet.perform_out_of_channel_notifications if Bullet.notification?
Bullet.end_request
end
end
end

View file

@ -4516,6 +4516,10 @@ prettier@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.5.3.tgz#59dadc683345ec6b88f88b94ed4ae7e1da394bfe"
prettysize@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/prettysize/-/prettysize-0.1.0.tgz#38ee534e2d298bc945fb7243203dd873cefc9679"
private@^0.1.6, private@^0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1"
@ -4550,7 +4554,7 @@ prop-types-extra@^1.0.1:
dependencies:
warning "^3.0.0"
prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.7, prop-types@^15.5.8:
prop-types@15.5.10, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.7, prop-types@^15.5.8:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
dependencies:
@ -4723,10 +4727,6 @@ react-intl@^2.3.0:
intl-relativeformat "^1.3.0"
invariant "^2.1.1"
react-moment@^0.6.4:
version "0.6.4"
resolved "https://registry.yarnpkg.com/react-moment/-/react-moment-0.6.4.tgz#5e531d47ad7b0bff6f6b7175093e98659f5e667b"
react-modal@^1.4.0:
version "1.9.7"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-1.9.7.tgz#07ef56790b953e3b98ef1e2989e347983c72871d"
@ -4738,6 +4738,10 @@ react-modal@^1.4.0:
prop-types "^15.5.7"
react-dom-factories "^1.0.0"
react-moment@^0.6.4:
version "0.6.4"
resolved "https://registry.yarnpkg.com/react-moment/-/react-moment-0.6.4.tgz#5e531d47ad7b0bff6f6b7175093e98659f5e667b"
react-overlays@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.7.0.tgz#531898ff566c7e5c7226ead2863b8cf9fbb5a981"
@ -4781,6 +4785,12 @@ react-router-dom@^4.1.2:
prop-types "^15.5.4"
react-router "^4.2.0"
react-router-prop-types@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/react-router-prop-types/-/react-router-prop-types-0.0.1.tgz#fd7fe4431f8ee104cdb250c738f410eb85847377"
dependencies:
prop-types "15.5.10"
react-router@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.2.0.tgz#61f7b3e3770daeb24062dae3eedef1b054155986"