Merge pull request #850 from ZmagoD/zd_SCI_1731

add scenic gem, teams datatables sql view
This commit is contained in:
Zmago Devetak 2017-11-09 11:37:20 +01:00 committed by GitHub
commit 7386974d64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1919 additions and 1216 deletions

View file

@ -66,6 +66,7 @@ gem 'delayed_paperclip',
gem 'rubyzip'
gem 'jbuilder' # JSON structures via a Builder-style DSL
gem 'activerecord-import'
gem 'scenic', '~> 1.4'
gem 'paperclip', '~> 5.1' # File attachment, image attachment library
gem 'aws-sdk', '~> 2'
@ -111,6 +112,7 @@ group :test do
gem 'poltergeist'
gem 'phantomjs', :require => 'phantomjs/poltergeist'
gem 'simplecov', require: false
gem 'json_matchers'
end
group :production do

View file

@ -244,6 +244,10 @@ GEM
js_cookie_rails (2.1.4)
railties (>= 3.1)
json (1.8.6)
json-schema (2.8.0)
addressable (>= 2.4)
json_matchers (0.7.2)
json-schema (~> 2.7)
kaminari (1.1.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.1.1)
@ -415,6 +419,9 @@ GEM
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
scenic (1.4.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
scss_lint (0.55.0)
rake (>= 0.9, < 13)
sass (~> 3.4.20)
@ -530,6 +537,7 @@ DEPENDENCIES
jquery-turbolinks
jquery-ui-rails
js_cookie_rails
json_matchers
kaminari
listen (~> 3.0)
logging (~> 2.0.0)
@ -559,6 +567,7 @@ DEPENDENCIES
rubyzip
sanitize (~> 4.4)
sass-rails (~> 5.0.6)
scenic (~> 1.4)
scss_lint
sdoc (~> 0.4.0)
shoulda-matchers

View file

@ -4,8 +4,9 @@ module ClientApi
include ClientApi::Users::UserTeamsHelper
def index
teams = current_user.datatables_teams
success_response(template: '/client_api/teams/index',
locals: { teams: current_user.teams_data })
locals: { teams: teams })
end
def create
@ -55,6 +56,11 @@ module ClientApi
error_response(message: error.to_s)
end
def current_team
success_response(template: '/client_api/teams/current_team',
locals: { team: current_user.current_team })
end
private
def team_params

View file

@ -1,51 +1,71 @@
// @flow
import React, { Component } from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { FormattedMessage } from "react-intl";
import { NavDropdown, MenuItem, Glyphicon } from "react-bootstrap";
import styled from "styled-components";
import _ from "lodash";
import { ROOT_PATH } from "../../../config/routes";
import { ROOT_PATH, SETTINGS_NEW_TEAM_ROUTE } from "../../../config/routes";
import { BORDER_GRAY_COLOR } from "../../../config/constants/colors";
import { changeTeam } from "../../actions/TeamsActions";
import { getTeamsList } from "../../actions/TeamsActions";
import { getCurrentTeam, changeTeam } from "../../actions/TeamsActions";
import { getTeams } from "../../../services/api/teams_api";
const StyledNavDropdown = styled(NavDropdown)`
border-left: 1px solid ${BORDER_GRAY_COLOR};
border-right: 1px solid ${BORDER_GRAY_COLOR};
`;
class TeamSwitch extends Component {
constructor(props) {
type State = {
allTeams: Array<Teams$Team>
}
type Props = {
current_team: Teams$CurrentTeam,
eventKey: string,
getCurrentTeam: Function,
changeTeam: Function
}
class TeamSwitch extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.displayTeams = this.displayTeams.bind(this);
(this: any).state = { allTeams: [] };
(this: any).displayTeams = this.displayTeams.bind(this);
(this: any).setTeams = this.setTeams.bind(this);
}
componentDidMount() {
this.props.getTeamsList();
this.props.getCurrentTeam();
}
setTeams() {
getTeams().then(response => (this: any).setState({ allTeams: response }));
}
changeTeam(teamId) {
this.props.changeTeam(teamId);
window.location = ROOT_PATH;
setTimeout(() => {
window.location = ROOT_PATH;
}, 1500);
}
displayTeams() {
if (!_.isEmpty(this.props.all_teams)) {
return this.props.all_teams
.filter(team => !team.current_team)
if (!_.isEmpty((this: any).state.allTeams)) {
return (this: any).state.allTeams
.filter(team => team.id !== this.props.current_team.id)
.map(team => (
<MenuItem onSelect={() => this.changeTeam(team.id)} key={team.id}>
{team.name}
</MenuItem>
));
}
return <MenuItem />;
}
newTeamLink() {
return (
<MenuItem href="/users/settings/teams/new" key="addNewTeam">
<MenuItem href={SETTINGS_NEW_TEAM_ROUTE} key="addNewTeam">
<Glyphicon glyph="plus" />&nbsp;
<FormattedMessage id="global_team_switch.new_team" />
</MenuItem>
@ -57,6 +77,7 @@ class TeamSwitch extends Component {
<StyledNavDropdown
noCaret
eventKey={this.props.eventKey}
onClick={this.setTeams}
title={
<span>
<i className="fa fa-users" />&nbsp;{this.props.current_team.name}
@ -72,38 +93,11 @@ class TeamSwitch extends Component {
}
}
TeamSwitch.propTypes = {
getTeamsList: PropTypes.func.isRequired,
eventKey: PropTypes.number.isRequired,
changeTeam: PropTypes.func.isRequired,
all_teams: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
current_team: PropTypes.bool.isRequired
}).isRequired
),
current_team: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
current_team: PropTypes.bool.isRequired
}).isRequired
};
// Map the states from store to component
const mapStateToProps = ({ all_teams, current_team }) => ({
current_team,
all_teams: all_teams.collection
const mapStateToProps = ({ current_team }) => ({
current_team
});
// Map the fetch activity action to component
const mapDispatchToProps = dispatch => ({
changeTeam(teamId) {
dispatch(changeTeam(teamId));
},
getTeamsList() {
dispatch(getTeamsList());
}
});
export default connect(mapStateToProps, mapDispatchToProps)(TeamSwitch);
export default connect(mapStateToProps, { getCurrentTeam, changeTeam })(
TeamSwitch
);

View file

@ -5,7 +5,11 @@ import type {
Actopm$SetCurrentTeam
} from "flow-typed";
import type { Dispatch } from "redux-thunk";
import { getTeams, changeCurrentTeam } from "../../services/api/teams_api";
import {
getTeams,
changeCurrentTeam,
getCurrentTeam as fetchCurrentTeam
} from "../../services/api/teams_api";
import { GET_LIST_OF_TEAMS, SET_CURRENT_TEAM } from "../../config/action_types";
export function addTeamsData(data: Array<Teams$Team>): Action$AddTeamData {
@ -15,7 +19,7 @@ export function addTeamsData(data: Array<Teams$Team>): Action$AddTeamData {
};
}
export function setCurrentTeam(team: Teams$Team): Actopm$SetCurrentTeam {
export function setCurrentTeam(team: Teams$CurrentTeam): Actopm$SetCurrentTeam {
return {
team,
type: SET_CURRENT_TEAM
@ -26,9 +30,7 @@ export function getTeamsList(): Dispatch {
return dispatch => {
getTeams()
.then(response => {
const { teams, currentTeam } = response;
dispatch(addTeamsData(teams));
dispatch(setCurrentTeam(currentTeam));
dispatch(addTeamsData(response));
})
.catch(error => {
console.log("get Teams Error: ", error);
@ -36,14 +38,16 @@ export function getTeamsList(): Dispatch {
};
}
export function getCurrentTeam(): Dispatch {
return dispatch => {
fetchCurrentTeam().then(response => dispatch(setCurrentTeam(response)));
};
}
export function changeTeam(teamID: number): Dispatch {
return dispatch => {
changeCurrentTeam(teamID)
.then(response => {
const { teams, currentTeam } = response;
dispatch(addTeamsData(teams));
dispatch(setCurrentTeam(currentTeam));
})
.then(response => dispatch(addTeamsData(response)))
.catch(error => {
console.log("get Teams Error: ", error);
});

View file

@ -8,7 +8,7 @@ import { leaveTeam } from "../../../../../services/api/teams_api";
import {
addTeamsData,
setCurrentTeam
getCurrentTeam
} from "../../../../../components/actions/TeamsActions";
type Team = {
@ -23,7 +23,7 @@ type Props = {
team: Team,
addTeamsData: Function,
hideLeaveTeamModal: Function,
setCurrentTeam: Function
getCurrentTeam: Function
};
class LeaveTeamModal extends Component<Props> {
@ -41,10 +41,9 @@ class LeaveTeamModal extends Component<Props> {
const { id, user_team_id } = this.props.team;
leaveTeam(id, user_team_id)
.then(response => {
const { teams, currentTeam } = response;
this.props.updateTeamsState(teams);
this.props.addTeamsData(teams);
this.props.setCurrentTeam(currentTeam);
this.props.updateTeamsState(response);
this.props.addTeamsData(response);
this.props.getCurrentTeam();
})
.catch(error => {
console.log("error: ", error.response.data.message);
@ -101,5 +100,5 @@ class LeaveTeamModal extends Component<Props> {
export default connect(null, {
addTeamsData,
setCurrentTeam
getCurrentTeam
})(LeaveTeamModal);

View file

@ -12,7 +12,6 @@ import LeaveTeamModal from "./LeaveTeamModal";
const DefaultTeam = {
id: 0,
name: "",
current_team: false,
user_team_id: 0,
role: "",
members: 0,

View file

@ -44,10 +44,10 @@ TeamsPageDetails.propTypes = {
PropTypes.shape({
id: number.isRequired,
name: string.isRequired,
current_team: bool.isRequired,
role: string.isRequired,
members: number.isRequired,
can_be_left: bool.isRequired
can_be_left: bool.isRequired,
user_team_id: number.isRequired
})
)
};

View file

@ -1,5 +1,4 @@
// @flow
import React, { Component } from "react";
import styled from "styled-components";
import { Breadcrumb } from "react-bootstrap";
@ -36,7 +35,7 @@ class SettingsTeams extends Component<Props, State> {
{
id: 0,
name: "",
current_team: true,
user_team_id: 0,
role: "",
members: 0,
can_be_left: false
@ -47,8 +46,8 @@ class SettingsTeams extends Component<Props, State> {
}
componentDidMount() {
getTeams().then(({ teams }) => {
this.updateTeamsState(teams);
getTeams().then(response => {
this.updateTeamsState(response);
});
// set team tab on active
this.props.tabState("2");

View file

@ -11,14 +11,15 @@ export const TEAMS_PATH = "/client_api/teams";
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";
export const CURRENT_USER_PATH = "/client_api/current_user_info"
export const CURRENT_TEAM_PATH = "/client_api/teams/current_team";
// users
export const USER_PROFILE_INFO = "/client_api/users/profile_info";
export const UPDATE_USER_PATH = "/client_api/users/update";
export const PREFERENCES_INFO_PATH = "/client_api/users/preferences_info"
export const STATISTICS_INFO_PATH = "/client_api/users/statistics_info"
export const SIGN_OUT_PATH = "/client_api/users/sign_out_user"
export const PREFERENCES_INFO_PATH = "/client_api/users/preferences_info";
export const STATISTICS_INFO_PATH = "/client_api/users/statistics_info";
export const SIGN_OUT_PATH = "/client_api/users/sign_out_user";
export const CURRENT_USER_PATH = "/client_api/current_user_info";
// info dropdown_title
export const CUSTOMER_SUPPORT_LINK = "http://scinote.net/support";

View file

@ -1,13 +1,13 @@
// @flow
import type { Teams$NewTeam, Team$Update } from "flow-typed";
import _ from "lodash";
import axiosInstance from "./config";
import {
TEAM_DETAILS_PATH,
TEAM_UPDATE_PATH,
TEAMS_PATH,
CHANGE_TEAM_PATH,
LEAVE_TEAM_PATH
LEAVE_TEAM_PATH,
CURRENT_TEAM_PATH
} from "./endpoints";
export const getTeamDetails = (teamID: number): Promise<*> => {
@ -24,24 +24,17 @@ export const updateTeam = (teamID: number, teamData: Team$Update): Promise<*> =>
.then(({ data }) => data.team);
export const getTeams = (): Promise<*> =>
axiosInstance.get(TEAMS_PATH).then(({ data }) => {
const teams = data.teams.collection;
const currentTeam = _.find(teams, team => team.current_team);
return { teams, currentTeam };
});
axiosInstance.get(TEAMS_PATH).then(({ data }) => data.teams);
export const changeCurrentTeam = (teamID: number): Promise<*> =>
axiosInstance.post(CHANGE_TEAM_PATH, { team_id: teamID }).then(({ data }) => {
const teams = data.teams.collection;
const currentTeam = _.find(teams, team => team.current_team);
return { teams, currentTeam };
});
axiosInstance
.post(CHANGE_TEAM_PATH, { team_id: teamID })
.then(({ data }) => data.teams);
export const leaveTeam = (teamID: number, userTeamID: number): Promise<*> => {
const teamUrl = `${LEAVE_TEAM_PATH}?team=${teamID}&user_team=${userTeamID}`;
return axiosInstance.delete(teamUrl).then(({ data }) => {
const teams = data.teams.collection;
const currentTeam = _.find(teams, team => team.current_team);
return { teams, currentTeam };
});
return axiosInstance.delete(teamUrl).then(({ data }) => data.teams);
};
export const getCurrentTeam = (): Promise<*> =>
axiosInstance.get(CURRENT_TEAM_PATH).then(({ data }) => data.team);

View file

@ -198,6 +198,7 @@ class User < ApplicationRecord
has_many :user_notifications, inverse_of: :user
has_many :notifications, through: :user_notifications
has_many :zip_exports, inverse_of: :user, dependent: :destroy
has_many :datatables_teams, class_name: '::Views::Datatables::DatatablesTeam'
# If other errors besides parameter "avatar" exist,
# they will propagate to "avatar" also, so remove them
@ -218,25 +219,6 @@ 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_left, 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]
)
)
end
# Search all active users for username & email. Can
# also specify which team to ignore.
def self.search(

View file

@ -0,0 +1,16 @@
module Views
module Datatables
class DatatablesTeam < ApplicationRecord
belongs_to :user
default_scope { order(name: :asc) }
private
# this isn't strictly necessary, but it will prevent
# rails from calling save, which would fail anyway.
def readonly?
true
end
end
end
end

View file

@ -31,7 +31,7 @@ module ClientApi
end
def teams_data
{ teams: @user.teams_data }
{ teams: @user.datatables_teams }
end
end

View file

@ -43,7 +43,7 @@ module ClientApi
def teams_data
{
teams: @user.teams_data,
teams: @user.datatables_teams,
flash_message: I18n.t('client_api.user_teams.leave_flash',
team: @team.name)
}

View file

@ -0,0 +1,4 @@
json.team do
json.id team.id
json.name team.name
end

View file

@ -1,11 +1,10 @@
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_left team.fetch('can_be_left')
json.user_team_id team.fetch('user_team_id')
json.array! teams do |team|
json.id team.id
json.name team.name
json.members team.members
json.role retrive_role_name(team.role)
json.can_be_left team.can_be_left
json.user_team_id team.user_team_id
end
end

View file

@ -4,6 +4,7 @@ get '/teams', to: 'teams/teams#index'
namespace :teams do
get '/new', to: 'teams#new'
get '/:team_id/details', to: 'teams#details'
get '/current_team', to: 'teams#current_team'
post '/', to: 'teams#create'
post '/change_team', to: 'teams#change_team'
post '/update', to: 'teams#update'

View file

@ -0,0 +1,5 @@
class CreateDatatablesTeams < ActiveRecord::Migration[5.0]
def change
create_view :datatables_teams
end
end

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
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.created_by_id = user_teams.user_id THEN false ELSE true END AS can_be_left,
user_teams.id AS user_team_id,
user_teams.user_id AS user_id
FROM teams INNER JOIN user_teams ON teams.id=user_teams.team_id

View file

@ -24,9 +24,13 @@ export type Teams$NewTeam = {
export type Teams$Team = {
id: number,
name: string,
current_team: boolean,
role: string,
members: number,
can_be_left: boolean,
user_team_id: number
};
export type Teams$CurrentTeam = {
id: number,
name: string
}

1505
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -128,4 +128,9 @@ describe ClientApi::Teams::TeamsController, type: :controller do
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'GET #current_team' do
let(:subject) { get :current_team, as: :json }
it { is_expected.to have_http_status(:ok) }
end
end

View file

@ -1,6 +1,5 @@
FactoryGirl.define do
factory :my_module_group do
name Faker::Name.unique.name
experiment { Experiment.first || create(:experiment_two) }
end
end

View file

@ -7,7 +7,6 @@ describe MyModuleGroup, type: :model do
describe 'Database table' do
it { should have_db_column :id }
it { should have_db_column :name }
it { should have_db_column :created_at }
it { should have_db_column :updated_at }
it { should have_db_column :created_by_id }
@ -21,8 +20,6 @@ describe MyModuleGroup, type: :model do
end
describe 'Should be a valid object' do
it { should validate_presence_of :name }
it { should validate_presence_of :experiment }
it { should validate_length_of(:name).is_at_most(Constants::NAME_MAX_LENGTH) }
end
end

View file

@ -152,40 +152,10 @@ describe User, type: :model do
end
let(:user_two) { create :user, email: 'user2@asdf.com' }
it 'in a specific format: {id: .., name: .., members: .., role: ' \
'.., current_team: .., can_be_left: ..}' do
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_left: false,
user_team_id: user_team.id
}
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
user_team = create :user_team, team: team, user: user_one
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_left: true,
user_team_id: user_team.id
}
user_one.teams_data.first.each do |k, v|
expect(v).to eq(expected_result.fetch(k.to_sym))
end
expect(user_one.datatables_teams.first.members).to eq 2
end
end

View file

@ -0,0 +1,23 @@
require 'rails_helper'
RSpec.describe Views::Datatables::DatatablesTeam, type: :model do
describe 'Database table' do
it { should have_db_column :id }
it { should have_db_column :name }
it { should have_db_column :members }
it { should have_db_column :role }
it { should have_db_column :user_team_id }
it { should have_db_column :user_id }
it { should have_db_column :can_be_left }
end
describe 'is readonly' do
let(:user) { create :user }
it do
expect {
Views::Datatables::DatatablesTeam.create!(user_id: user.id)
}.to raise_error(ActiveRecord::ReadOnlyRecord,
'Views::Datatables::DatatablesTeam is marked as readonly')
end
end
end

View file

@ -55,10 +55,11 @@ describe ClientApi::TeamsService do
ClientApi::TeamsService.new(current_user: user_one, team_id: team_one.id)
end
it 'should return user teams' do
it 'should return an array of valid 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
expect(team_service.teams_data).to(
match_response_schema('datatables_teams')
)
end
end

View file

@ -130,7 +130,7 @@ describe ClientApi::UserTeamService do
user_team_id: user_team.id,
role: 1
)
team_id = ut_service.teams_data[:teams].first.fetch('id')
team_id = ut_service.teams_data[:teams].first.id
expect(team_id).to eq team_one.id
end
end

View file

@ -18,6 +18,7 @@ require 'simplecov'
require 'faker'
require 'active_record'
require 'bullet'
require "json_matchers/rspec"
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate

View file

@ -0,0 +1,21 @@
{
"type": "object",
"required": ["teams"],
"properties": {
"teams": {
"type": "array",
"items":{
"required": ["id", "name", "members", "role", "can_be_left", "user_team_id"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"members": { "type": "integer" },
"role": { "type": "integer" },
"can_be_left": { "type": "boolean" },
"user_team_id": { "type": "integer" },
"user_id": { "type": "integer" }
}
}
}
}
}