diff --git a/Gemfile b/Gemfile index e1c48b798..23e6b12d4 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index a4c3f3265..f29f17386 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/client_api/teams/teams_controller.rb b/app/controllers/client_api/teams/teams_controller.rb new file mode 100644 index 000000000..e97e03daa --- /dev/null +++ b/app/controllers/client_api/teams/teams_controller.rb @@ -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 diff --git a/app/controllers/client_api/teams_controller.rb b/app/controllers/client_api/teams_controller.rb deleted file mode 100644 index 0ffba958a..000000000 --- a/app/controllers/client_api/teams_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/client_api/users/user_teams_controller.rb b/app/controllers/client_api/users/user_teams_controller.rb index de1a39f8f..bf66fa4ff 100644 --- a/app/controllers/client_api/users/user_teams_controller.rb +++ b/app/controllers/client_api/users/user_teams_controller.rb @@ -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 diff --git a/app/javascript/packs/app/action_types.js b/app/javascript/packs/app/action_types.js index b14c08712..b2d3a7443 100644 --- a/app/javascript/packs/app/action_types.js +++ b/app/javascript/packs/app/action_types.js @@ -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"; diff --git a/app/javascript/packs/app/constants/colors.js b/app/javascript/packs/app/constants/colors.js index 55972ab92..15e7f0471 100644 --- a/app/javascript/packs/app/constants/colors.js +++ b/app/javascript/packs/app/constants/colors.js @@ -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"; diff --git a/app/javascript/packs/app/constants/numeric.js b/app/javascript/packs/app/constants/numeric.js index dc04372b4..1ed90ac4d 100644 --- a/app/javascript/packs/app/constants/numeric.js +++ b/app/javascript/packs/app/constants/numeric.js @@ -1 +1,3 @@ export const ENTER_KEY_CODE = 13; +export const TEXT_MAX_LENGTH = 10000; +export const NAME_MAX_LENGTH = 255; diff --git a/app/javascript/packs/app/dom_routes.js b/app/javascript/packs/app/dom_routes.js new file mode 100644 index 000000000..f04d2ae72 --- /dev/null +++ b/app/javascript/packs/app/dom_routes.js @@ -0,0 +1,3 @@ +// Settings page +export const SETTINGS_TEAMS_ROUTE = "/settings/teams"; +export const SETTINGS_TEAM_ROUTE = "/settings/teams/:id"; diff --git a/app/javascript/packs/app/reducers.js b/app/javascript/packs/app/reducers.js index 0cfd67fc9..9ae33fb42 100644 --- a/app/javascript/packs/app/reducers.js +++ b/app/javascript/packs/app/reducers.js @@ -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, diff --git a/app/javascript/packs/app/routes.js b/app/javascript/packs/app/routes.js index 9ab561127..9515ea989 100644 --- a/app/javascript/packs/app/routes.js +++ b/app/javascript/packs/app/routes.js @@ -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"; diff --git a/app/javascript/packs/locales/messages.js b/app/javascript/packs/locales/messages.js index 7932e3533..a2b7ada18 100644 --- a/app/javascript/packs/locales/messages.js +++ b/app/javascript/packs/locales/messages.js @@ -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 Owner 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 Owner 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 Owner 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: {created_at}", + created_by: "Created by: {created_by}", + space_usage: "Space usage: {space_usage}", + no_description: "No description", + 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", diff --git a/app/javascript/packs/shared/actions/LeaveTeamActions.js b/app/javascript/packs/shared/actions/LeaveTeamActions.js deleted file mode 100644 index ffd489a56..000000000 --- a/app/javascript/packs/shared/actions/LeaveTeamActions.js +++ /dev/null @@ -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 - }; -} diff --git a/app/javascript/packs/shared/actions/TeamsActions.js b/app/javascript/packs/shared/actions/TeamsActions.js index 40f4b7295..3dd85ab17 100644 --- a/app/javascript/packs/shared/actions/TeamsActions.js +++ b/app/javascript/packs/shared/actions/TeamsActions.js @@ -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)); diff --git a/app/javascript/packs/shared/modals_container/modals/LeaveTeamModal.jsx b/app/javascript/packs/shared/modals_container/modals/LeaveTeamModal.jsx index 790ba3a59..4de293378 100644 --- a/app/javascript/packs/shared/modals_container/modals/LeaveTeamModal.jsx +++ b/app/javascript/packs/shared/modals_container/modals/LeaveTeamModal.jsx @@ -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 { @@ -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, { diff --git a/app/javascript/packs/shared/reducers/LeaveTeamReducer.js b/app/javascript/packs/shared/reducers/LeaveTeamReducer.js deleted file mode 100644 index a784bc582..000000000 --- a/app/javascript/packs/shared/reducers/LeaveTeamReducer.js +++ /dev/null @@ -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; -} diff --git a/app/javascript/packs/shared/reducers/TeamReducers.js b/app/javascript/packs/shared/reducers/TeamReducers.js index a3ca8a7c5..0df2a6417 100644 --- a/app/javascript/packs/shared/reducers/TeamReducers.js +++ b/app/javascript/packs/shared/reducers/TeamReducers.js @@ -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; +}; diff --git a/app/javascript/packs/src/settings/components/MainNav.jsx b/app/javascript/packs/src/settings/components/MainNav.jsx index 5efd85c92..9a00647d8 100644 --- a/app/javascript/packs/src/settings/components/MainNav.jsx +++ b/app/javascript/packs/src/settings/components/MainNav.jsx @@ -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={() => } /> - - + + diff --git a/app/javascript/packs/src/settings/components/team/SettingsTeamPageContainer.jsx b/app/javascript/packs/src/settings/components/team/SettingsTeamPageContainer.jsx new file mode 100644 index 000000000..e54e8f08c --- /dev/null +++ b/app/javascript/packs/src/settings/components/team/SettingsTeamPageContainer.jsx @@ -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 ( + + ); + } + + renderEditNameModel() { + if (this.state.showNameModal) { + return( + + ); + } + } + + render() { + return ( + + +
  • + + + +
  • +
  • + {this.state.team.name} +
  • +
    + + + {this.state.team.name} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {this.renderDescription()} + + + + + + + {this.renderEditNameModel()} +
    + ); + } +} + +SettingsTeamPageContainer.PropTypes = { + match: ReactRouterPropTypes.match.isRequired +}; + +export default SettingsTeamPageContainer; diff --git a/app/javascript/packs/src/settings/components/team/actions/UserTeamsActions.js b/app/javascript/packs/src/settings/components/team/actions/UserTeamsActions.js deleted file mode 100644 index 33f07eb5a..000000000 --- a/app/javascript/packs/src/settings/components/team/actions/UserTeamsActions.js +++ /dev/null @@ -1,2 +0,0 @@ - -import { LEAVE_TEAM_PATH } from '../../../../../app/routes' diff --git a/app/javascript/packs/src/settings/components/team/components/RemoveUserModal.jsx b/app/javascript/packs/src/settings/components/team/components/RemoveUserModal.jsx new file mode 100644 index 000000000..a6670dd0f --- /dev/null +++ b/app/javascript/packs/src/settings/components/team/components/RemoveUserModal.jsx @@ -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 ( + + + + + + + +

    + +

    + +   + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    + + + + +
    + ); + } +} + +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; diff --git a/app/javascript/packs/src/settings/components/team/components/TeamsMembers.jsx b/app/javascript/packs/src/settings/components/team/components/TeamsMembers.jsx new file mode 100644 index 000000000..510067d8b --- /dev/null +++ b/app/javascript/packs/src/settings/components/team/components/TeamsMembers.jsx @@ -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 ? : " "; + } + + 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 ( + + + + } + id="actions-dropdown" + > + + + + { + // 0 => Guest + this.updateRole(data.team_user_id, 0); + }} + > + {this.currentRole(data.current_role, "Guest")} + + + { + // 1 => Normal user + this.updateRole(data.team_user_id, 1); + }} + > + {this.currentRole(data.current_role, "Normal user")} + + + { + // 2 => Administrator + this.updateRole(data.team_user_id, 2); + }} + > + {this.currentRole(data.current_role, "Administrator")} + + + + { + this.userToRemove({ + userName: row.name, + team_user_id: data.team_user_id, + teamName: this.props.team.name, + team_id: this.props.team.id + }); + }} + > + + + + ); + } + + 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 ( + + } + > + + + + + + ); + } +} + +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; diff --git a/app/javascript/packs/src/settings/components/team/components/UpdateTeamDescriptionModal.jsx b/app/javascript/packs/src/settings/components/team/components/UpdateTeamDescriptionModal.jsx new file mode 100644 index 000000000..5ffb57a2a --- /dev/null +++ b/app/javascript/packs/src/settings/components/team/components/UpdateTeamDescriptionModal.jsx @@ -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: ( + + ) + }); + } 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 ( + + + + + + + + + + + + + + + {this.state.errorMessage} + + + + + + + + + ); + } +} + +UpdateTeamDescriptionModal.propTypes = { + showModal: bool.isRequired, + hideModal: func.isRequired, + team: PropTypes.shape({ + id: number.isRequired, + description: string + }).isRequired, + updateTeamCallback: func.isRequired +}; + +export default UpdateTeamDescriptionModal; diff --git a/app/javascript/packs/src/settings/components/team/components/UpdateTeamNameModal.jsx b/app/javascript/packs/src/settings/components/team/components/UpdateTeamNameModal.jsx new file mode 100644 index 000000000..47005bef7 --- /dev/null +++ b/app/javascript/packs/src/settings/components/team/components/UpdateTeamNameModal.jsx @@ -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: ( + + ) + }); + } 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 ( + + + + + + + + + + + + + + + {this.state.errorMessage} + + + + + + + + + ); + } +} + +UpdateTeamNameModal.propTypes = { + showModal: bool.isRequired, + hideModal: func.isRequired, + team: PropTypes.shape({ + id: number.isRequired, + name: string + }).isRequired, + updateTeamCallback: func.isRequired +}; + +export default UpdateTeamNameModal; diff --git a/app/javascript/packs/src/settings/components/team/SettingsTeams.jsx b/app/javascript/packs/src/settings/components/teams/SettingsTeams.jsx similarity index 100% rename from app/javascript/packs/src/settings/components/team/SettingsTeams.jsx rename to app/javascript/packs/src/settings/components/teams/SettingsTeams.jsx diff --git a/app/javascript/packs/src/settings/components/team/components/TeamsDataTable.jsx b/app/javascript/packs/src/settings/components/teams/components/TeamsDataTable.jsx similarity index 81% rename from app/javascript/packs/src/settings/components/team/components/TeamsDataTable.jsx rename to app/javascript/packs/src/settings/components/teams/components/TeamsDataTable.jsx index e638f9516..2e97cb7e1 100644 --- a/app/javascript/packs/src/settings/components/team/components/TeamsDataTable.jsx +++ b/app/javascript/packs/src/settings/components/teams/components/TeamsDataTable.jsx @@ -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 ( - ); @@ -36,6 +36,14 @@ class TeamsDataTable extends Component { ); } + linkToTeam(name, row) { + return ( + + {name} + + ); + } + 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 }, diff --git a/app/javascript/packs/src/settings/components/team/components/TeamsPageDetails.jsx b/app/javascript/packs/src/settings/components/teams/components/TeamsPageDetails.jsx similarity index 100% rename from app/javascript/packs/src/settings/components/team/components/TeamsPageDetails.jsx rename to app/javascript/packs/src/settings/components/teams/components/TeamsPageDetails.jsx diff --git a/app/javascript/packs/styles/main.scss b/app/javascript/packs/styles/main.scss index 25af11341..d24494f08 100644 --- a/app/javascript/packs/styles/main.scss +++ b/app/javascript/packs/styles/main.scss @@ -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; + } +} diff --git a/app/models/user.rb b/app/models/user.rb index b6ced8b27..449110ed8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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] ) diff --git a/app/services/client_api/teams_service.rb b/app/services/client_api/teams_service.rb new file mode 100644 index 000000000..e62dbd226 --- /dev/null +++ b/app/services/client_api/teams_service.rb @@ -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 diff --git a/app/services/client_api/user_team_service.rb b/app/services/client_api/user_team_service.rb new file mode 100644 index 000000000..80c98e2b3 --- /dev/null +++ b/app/services/client_api/user_team_service.rb @@ -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 diff --git a/app/views/client_api/teams/details.json.jbuilder b/app/views/client_api/teams/details.json.jbuilder new file mode 100644 index 000000000..49c565d9c --- /dev/null +++ b/app/views/client_api/teams/details.json.jbuilder @@ -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 diff --git a/app/views/client_api/teams/index.json.jbuilder b/app/views/client_api/teams/index.json.jbuilder index e58e8356c..f6958099d 100644 --- a/app/views/client_api/teams/index.json.jbuilder +++ b/app/views/client_api/teams/index.json.jbuilder @@ -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 diff --git a/app/views/client_api/teams/team_users.json.jbuilder b/app/views/client_api/teams/team_users.json.jbuilder new file mode 100644 index 000000000..2214b5a90 --- /dev/null +++ b/app/views/client_api/teams/team_users.json.jbuilder @@ -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 diff --git a/app/views/client_api/teams/update_details.json.jbuilder b/app/views/client_api/teams/update_details.json.jbuilder new file mode 100644 index 000000000..84dd9d9b2 --- /dev/null +++ b/app/views/client_api/teams/update_details.json.jbuilder @@ -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 diff --git a/config/environments/test.rb b/config/environments/test.rb index 8562e03ce..c35f9ba75 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 07989c05b..c266d562c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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}." diff --git a/config/routes.rb b/config/routes.rb index 01da9f7c2..7e595a242 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/package.json b/package.json index 10fcfa26a..ad2c953f7 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/spec/controllers/client_api/teams/teams_controller_spec.rb b/spec/controllers/client_api/teams/teams_controller_spec.rb new file mode 100644 index 000000000..9ec6ba221 --- /dev/null +++ b/spec/controllers/client_api/teams/teams_controller_spec.rb @@ -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 diff --git a/spec/controllers/client_api/users/user_teams_controller_spec.rb b/spec/controllers/client_api/users/user_teams_controller_spec.rb index d1e0604bd..6e9fccea7 100644 --- a/spec/controllers/client_api/users/user_teams_controller_spec.rb +++ b/spec/controllers/client_api/users/user_teams_controller_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8a118e209..015c0dac1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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| diff --git a/spec/services/client_api/teams_service_spec.rb b/spec/services/client_api/teams_service_spec.rb new file mode 100644 index 000000000..201b2818c --- /dev/null +++ b/spec/services/client_api/teams_service_spec.rb @@ -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 diff --git a/spec/services/client_api/user_team_service_spec.rb b/spec/services/client_api/user_team_service_spec.rb new file mode 100644 index 000000000..20ba3d102 --- /dev/null +++ b/spec/services/client_api/user_team_service_spec.rb @@ -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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2b2309f8c..b24b6926a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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 diff --git a/yarn.lock b/yarn.lock index ba14d0a0e..9e8f9b798 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"