Merge pull request #778 from ZmagoD/zd_SCI_1497

Rewrite all teams page into React.js
This commit is contained in:
Zmago Devetak 2017-08-29 13:36:04 +02:00 committed by GitHub
commit 6159b18533
30 changed files with 626 additions and 32 deletions

View file

@ -1,5 +1,6 @@
module ClientApi
class TeamsController < ApplicationController
include ClientApi::Users::UserTeamsHelper
MissingTeamError = Class.new(StandardError)
def index
@ -34,11 +35,11 @@ module ClientApi
end
def teams
{ teams: current_user.teams }
{ teams: current_user.teams_data }
end
def change_current_team
team_id = params.fetch(:team_id) { raise MissingTeamError }
team_id = params.fetch(:team_id) { raise MissingTeamError }
unless current_user.teams.pluck(:id).include? team_id
raise MissingTeamError
end

View file

@ -0,0 +1,86 @@
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
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
respond_to do |format|
# return a list of teams
format.json do
render template: '/client_api/teams/index',
status: :ok,
locals: {
teams: current_user.teams_data,
flash_message: t('client_api.user_teams.leave_flash',
team: @team.name)
}
end
end
end
def unsuccess_response
respond_to do |format|
format.json do
render json: { message: t(
'client_api.user_teams.leave_team_error'
) },
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

@ -0,0 +1,10 @@
module ClientApi
module Users
module UserTeamsHelper
def retrive_role_name(index)
return unless index
['Guest', 'Normal user', 'Administrator'].at(index)
end
end
end
end

View file

@ -23,3 +23,7 @@ export const CHANGE_RECENT_NOTIFICATION_EMAIL =
"CHANGE_RECENT_NOTIFICATION_EMAIL";
export const CHANGE_SYSTEM_MESSAGE_NOTIFICATION_EMAIL =
"CHANGE_SYSTEM_MESSAGE_NOTIFICATION_EMAIL";
// user teams
export const LEAVE_TEAM = "LEAVE_TEAM"
export const SHOW_LEAVE_TEAM_MODAL = "SHOW_LEAVE_TEAM_MODAL"

View file

@ -5,10 +5,12 @@ import {
} 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,
all_teams: getListOfTeams,
global_activities: globalActivities,
current_user: currentUser
current_user: currentUser,
showLeaveTeamModal
});

View file

@ -22,6 +22,9 @@ export const PREMIUM_LINK = "http://scinote.net/premium/";
export const CONTACT_US_LINK =
"http://scinote.net/story-of-scinote/#contact-scinote";
// user teams
export const LEAVE_TEAM_PATH = "/client_api/users/leave_team"
// settings
export const SETTINGS_ACCOUNT_PROFILE = "/settings/account/profile";
export const SETTINGS_ACCOUNT_PREFERENCES = "/settings/account/preferences";

View file

@ -18,6 +18,19 @@ export default {
info_label: "Info"
},
settings_page: {
all_teams: "All teams",
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",

View file

@ -0,0 +1,8 @@
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

@ -3,16 +3,16 @@ import _ from "lodash";
import { TEAMS_PATH, CHANGE_TEAM_PATH } from "../../app/routes";
import { GET_LIST_OF_TEAMS, SET_CURRENT_TEAM } from "../../app/action_types";
function addTeamsData(data) {
export function addTeamsData(data) {
return {
type: GET_LIST_OF_TEAMS,
payload: data
};
}
export function setCurrentUser(user) {
export function setCurrentTeam(team) {
return {
user,
team,
type: SET_CURRENT_TEAM
};
}
@ -22,10 +22,10 @@ export function getTeamsList() {
axios
.get(TEAMS_PATH, { withCredentials: true })
.then(response => {
let teams = _.values(response.data);
const teams = response.data.teams.collection;
dispatch(addTeamsData(teams));
let current_team = _.find(teams, team => team.current_team);
dispatch(setCurrentUser(current_team));
const currentTeam = _.find(teams, team => team.current_team);
dispatch(setCurrentTeam(currentTeam));
})
.catch(error => {
console.log("get Teams Error: ", error);
@ -33,15 +33,15 @@ export function getTeamsList() {
};
}
export function changeTeam(team_id) {
export function changeTeam(teamId) {
return dispatch => {
axios
.post(CHANGE_TEAM_PATH, { team_id }, { withCredentials: true })
.post(CHANGE_TEAM_PATH, { teamId }, { withCredentials: true })
.then(response => {
let teams = _.values(response.data);
const teams = response.data.teams.collection;
dispatch(addTeamsData(teams));
let current_team = _.find(teams, team => team.current_team);
dispatch(setCurrentUser(current_team));
const currentTeam = _.find(teams, team => team.current_team);
dispatch(setCurrentTeam(currentTeam));
})
.catch(error => {
console.log("get Teams Error: ", error);

View file

@ -108,4 +108,4 @@ DataTable.propTypes = {
data: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default DataTable;
export default DataTable;

View file

@ -0,0 +1,7 @@
import React from "react";
import LeaveTeamModal from "./modals/LeaveTeamModal";
export default () =>
<div>
<LeaveTeamModal />
</div>;

View file

@ -0,0 +1,103 @@
import React, { Component } from "react";
import PropTypes, { bool, number, string, func } from "prop-types";
import { Modal, Button, Alert, Glyphicon } from "react-bootstrap";
import { FormattedMessage, FormattedHTMLMessage } from "react-intl";
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";
class LeaveTeamModal extends Component {
constructor(props) {
super(props);
this.onCloseModal = this.onCloseModal.bind(this);
this.leaveTeam = this.leaveTeam.bind(this);
}
onCloseModal() {
this.props.leaveTeamModalShow(false);
}
leaveTeam() {
const teamUrl = `${LEAVE_TEAM_PATH}?team=${this.props.teamId}`;
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);
this.props.setCurrentTeam(currentTeam);
})
.catch(error => {
console.log("error: ", error.response.data.message);
});
this.props.leaveTeamModalShow(false);
}
render() {
return (
<Modal show={this.props.showModal} onHide={this.onCloseModal}>
<Modal.Header closeButton>
<Modal.Title>
<FormattedMessage
id="settings_page.leave_team_modal.title"
values={{ teamName: this.props.teamName }}
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
<FormattedMessage id="settings_page.leave_team_modal.subtitle" />
</p>
<Alert bsStyle="danger">
<Glyphicon glyph="exclamation-sign" />&nbsp;
<FormattedMessage id="settings_page.leave_team_modal.warnings" />
<ul>
<li>
<FormattedMessage id="settings_page.leave_team_modal.warning_message_one" />
</li>
<li>
<FormattedHTMLMessage id="settings_page.leave_team_modal.warning_message_two" />
</li>
<li>
<FormattedMessage id="settings_page.leave_team_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.leaveTeam}>
<FormattedMessage id="settings_page.leave_team_modal.leave_team" />
</Button>
</Modal.Footer>
</Modal>
);
}
}
LeaveTeamModal.propTypes = {
showModal: bool.isRequired,
teamId: number.isRequired,
teamName: string.isRequired,
addTeamsData: func.isRequired,
leaveTeamModalShow: func.isRequired
};
const mapStateToProps = ({ showLeaveTeamModal }) => ({
showModal: showLeaveTeamModal.show,
teamId: showLeaveTeamModal.id,
teamName: showLeaveTeamModal.teamName
});
export default connect(mapStateToProps, {
leaveTeamModalShow,
addTeamsData,
setCurrentTeam
})(LeaveTeamModal);

View file

@ -7,7 +7,7 @@ import styled from "styled-components";
import _ from "lodash";
import { BORDER_GRAY_COLOR } from "../../../app/constants/colors";
import { setCurrentUser, changeTeam } from "../../actions/TeamsActions";
import { changeTeam } from "../../actions/TeamsActions";
import { getTeamsList } from "../../actions/TeamsActions";
const StyledNavDropdown = styled(NavDropdown)`
@ -89,14 +89,11 @@ TeamSwitch.propTypes = {
// Map the states from store to component
const mapStateToProps = ({ all_teams, current_team }) => ({
current_team,
all_teams: _.values(all_teams)
all_teams: all_teams.collection
});
// Map the fetch activity action to component
const mapDispatchToProps = dispatch => ({
setCurrentUser() {
dispatch(setCurrentUser());
},
changeTeam(teamId) {
dispatch(changeTeam(teamId));
},

View file

@ -0,0 +1,11 @@
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

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

View file

@ -10,6 +10,7 @@ import store from "../../app/store";
import messages from "../../locales/messages";
import MainNav from "./components/MainNav";
import ModalsContainer from "../../shared/modals_container";
addLocaleData([...enLocaleData]);
const locale = "en-US";
@ -17,6 +18,7 @@ const locale = "en-US";
const SettingsPage = () =>
<div>
<MainNav />
<ModalsContainer />
</div>;
document.addEventListener("DOMContentLoaded", () => {

View file

@ -1,7 +1,16 @@
import React from "react";
import PropTypes, { number, string, bool } from "prop-types";
import styled from "styled-components";
import { connect } from "react-redux";
import { FormattedMessage } from "react-intl";
import { BORDER_LIGHT_COLOR } from "../../../../app/constants/colors";
import {
BORDER_LIGHT_COLOR,
COLOR_CONCRETE
} from "../../../../app/constants/colors";
import TeamsPageDetails from "./components/TeamsPageDetails";
import TeamsDataTable from "./components/TeamsDataTable";
const Wrapper = styled.div`
background: white;
@ -9,12 +18,42 @@ const Wrapper = styled.div`
border: 1px solid ${BORDER_LIGHT_COLOR};
border-top: none;
margin: 0;
padding: 16px 0 50px 0;
padding: 16px 15px 50px 15px;
`;
const SettingsTeams = () =>
const TabTitle = styled.div`
background-color: ${COLOR_CONCRETE};
padding: 15px;
`;
const SettingsTeams = ({ teams }) =>
<Wrapper>
<h1 className="text-center">Settings Teams</h1>
<TabTitle>
<FormattedMessage id="settings_page.all_teams" />
</TabTitle>
<TeamsPageDetails teams={teams} />
<TeamsDataTable teams={teams} />
</Wrapper>;
export default SettingsTeams;
SettingsTeams.propTypes = {
teams: PropTypes.arrayOf(
PropTypes.shape({
id: number.isRequired,
name: string.isRequired,
current_team: bool.isRequired,
role: string.isRequired,
members: number.isRequired,
can_be_leaved: bool.isRequired
}).isRequired
)
};
SettingsTeams.defaultProps = {
teams: [{id: 0, name: "", current_team: "", role: "", members: 0}]
};
const mapStateToProps = ({ all_teams }) => ({
teams: all_teams.collection
});
export default connect(mapStateToProps)(SettingsTeams);

View file

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

View file

@ -0,0 +1,106 @@
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 { FormattedMessage } from "react-intl";
import { leaveTeamModalShow } from "../../../../../shared/actions/LeaveTeamActions";
import DataTable from "../../../../../shared/data_table";
class TeamsDataTable extends Component {
constructor(props) {
super(props);
this.leaveTeamModal = this.leaveTeamModal.bind(this);
this.leaveTeamButton = this.leaveTeamButton.bind(this);
}
leaveTeamModal(e, id) {
const team = _.find(this.props.teams, el => el.id === id);
this.props.leaveTeamModalShow(true, id, team.name);
}
leaveTeamButton(id) {
const team = _.find(this.props.teams, el => el.id === id);
if (team.can_be_leaved) {
return (
<Button onClick={e => this.leaveTeamModal(e, id)}>
<FormattedMessage id="settings_page.leave_team" />
</Button>
);
}
return (
<Button disabled>
<FormattedMessage id="settings_page.leave_team" />
</Button>
);
}
render() {
const options = {
defaultSortName: "name",
defaultSortOrder: "desc",
sizePerPageList: [10, 25, 50, 100],
paginationPosition: "top",
alwaysShowAllBtns: false
};
const columns = [
{
id: 1,
name: "Name",
isKey: false,
textId: "name",
position: 0,
dataSort: true
},
{
id: 2,
name: "Role",
isKey: false,
textId: "role",
position: 1,
dataSort: true
},
{
id: 3,
name: "Members",
isKey: false,
textId: "members",
position: 2,
dataSort: true
},
{
id: 4,
name: "",
isKey: true,
textId: "id",
dataFormat: this.leaveTeamButton,
position: 3
}
];
return (
<DataTable
data={this.props.teams}
columns={columns}
pagination={true}
options={options}
/>
);
}
}
TeamsDataTable.propTypes = {
leaveTeamModalShow: func.isRequired,
teams: PropTypes.arrayOf(
PropTypes.shape({
id: number.isRequired,
name: string.isRequired,
current_team: bool.isRequired,
role: string.isRequired,
members: number.isRequired,
can_be_leaved: bool.isRequired
}).isRequired
)
};
export default connect(null, { leaveTeamModalShow })(TeamsDataTable);

View file

@ -0,0 +1,59 @@
import React from "react";
import PropTypes, { number, string, bool } from "prop-types";
import styled from "styled-components";
import { FormattedMessage, FormattedPlural } from "react-intl";
import { Button, Glyphicon } from "react-bootstrap";
const Wrapper = styled.div`margin: 15px 0;`;
const TeamsPageDetails = ({ teams }) => {
const teamsNumber = teams.length;
return (
<Wrapper>
<FormattedPlural
value={teamsNumber}
one={
<FormattedMessage
id="settings_page.in_team"
values={{
num: teamsNumber
}}
/>
}
other={
<FormattedMessage
id="settings_page.in_teams"
values={{
num: teamsNumber
}}
/>
}
/>&nbsp;
<Button
onClick={() => {
window.location = "/users/settings/teams/new";
}}
>
<Glyphicon glyph="plus" />&nbsp;<FormattedMessage id="global_team_switch.new_team" />
</Button>
</Wrapper>
);
};
TeamsPageDetails.propTypes = {
teams: PropTypes.arrayOf(
PropTypes.shape({
id: number.isRequired,
name: string.isRequired,
current_team: bool.isRequired,
role: string.isRequired,
members: number.isRequired,
can_be_leaved: bool.isRequired
})
)
};
TeamsPageDetails.defaultProps = {
teams: []
};
export default TeamsPageDetails;

View file

@ -204,6 +204,24 @@ class User < ApplicationRecord
Team.find_by_id(self.current_team_id)
end
# Retrieves the data needed in all teams page
def teams_data
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.send(
:sanitize_sql_array,
['SELECT teams.id AS id, teams.name AS name, user_teams.role ' \
'AS role, (SELECT COUNT(*) FROM user_teams WHERE ' \
'user_teams.team_id = teams.id) AS members, ' \
'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 ' \
'teams.id=user_teams.team_id WHERE user_teams.user_id=?',
self.current_team_id, self.id]
)
)
end
# Search all active users for username & email. Can
# also specify which team to ignore.
def self.search(

View file

@ -1,5 +1,10 @@
json.array! teams do |team|
json.id team.id
json.name team.name
json.current_team team == current_user.current_team
json.teams do
json.collection teams do |team|
json.id team.fetch('id')
json.name team.fetch('name')
json.members team.fetch('members')
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')
end
end

View file

@ -0,0 +1,11 @@
json.teams do
json.flash_message flash_message
json.collection teams do |team|
json.id team.fetch('id')
json.name team.fetch('name')
json.members team.fetch('members')
json.role 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')
end
end

View file

@ -1819,3 +1819,8 @@ en:
More: "More"
Added: 'Added'
by: 'by'
client_api:
user_teams:
leave_team_error: "An error occured."
leave_flash: "Successfuly left team %{team}."

View file

@ -23,6 +23,10 @@ Rails.application.routes.draw do
get '/recent_notifications', to: 'notifications#recent_notifications'
# users
get '/current_user_info', to: 'users#current_user_info'
namespace :users do
delete '/leave_team', to: 'user_teams#leave_team'
end
end
# Save sample table state

View file

@ -0,0 +1,32 @@
require 'rails_helper'
describe ClientApi::Users::UserTeamsController, type: :controller do
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
expect(response).to be_success
expect(response).to have_http_status(200)
end
it 'Returns HTTP unprocessable_entity if user can\'t leave the team' do
delete :leave_team, params: { team: @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
delete :leave_team, format: :json
expect(response).to_not be_success
expect(response).to have_http_status(:unprocessable_entity)
end
end
end

View file

@ -0,0 +1,5 @@
FactoryGirl.define do
factory :user_team do
role 'admin'
end
end

View file

@ -149,4 +149,47 @@ describe User, type: :model do
expect(user.name).to eq 'Axe'
end
end
describe 'teams_data should return a list of teams' do
# needs persistence because is testing a sql query
let(:team) { create :team }
let(:user_one) do
create :user, email: 'user1@asdf.com', current_team_id: team.id
end
let(:user_two) { create :user, email: 'user2@asdf.com' }
it 'in a specific format: {id: .., name: .., members: .., role: ' \
'.., current_team: .., can_be_leaved: ..}' do
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
}
user_one.teams_data.first.each do |k, v|
expect(v).to eq(expected_result.fetch(k.to_sym))
end
end
it 'should return correct number of team members' do
create :user_team, team: team, user: user_one
create :user_team, team: team, user: user_two
expected_result = {
id: team.id,
name: team.name,
members: 2,
role: 2,
current_team: true,
can_be_leaved: true
}
user_one.teams_data.first.each do |k, v|
expect(v).to eq(expected_result.fetch(k.to_sym))
end
end
end
end

View file

@ -2,6 +2,8 @@
require 'spec_helper'
require 'shoulda-matchers'
require 'database_cleaner'
require 'devise'
require_relative 'support/controller_macros'
ENV['RAILS_ENV'] = 'test'
require File.expand_path('../../config/environment', __FILE__)
# Prevent database truncation if the environment is production
@ -78,6 +80,9 @@ RSpec.configure do |config|
# includes FactoryGirl in rspec
config.include FactoryGirl::Syntax::Methods
# Devise
config.include Devise::Test::ControllerHelpers, type: :controller
config.extend ControllerMacros, type: :controller
end
# config shoulda matchers to work with rspec

View file

@ -0,0 +1,10 @@
module ControllerMacros
def login_user
before(:each) do
@request.env['devise.mapping'] = Devise.mappings[:user]
user = FactoryGirl.create(:user)
user.confirm
sign_in user
end
end
end