diff --git a/app/javascript/src/components/AlertsContainer/components/Alert.jsx b/app/javascript/src/components/AlertsContainer/components/Alert.jsx new file mode 100644 index 000000000..e664093a0 --- /dev/null +++ b/app/javascript/src/components/AlertsContainer/components/Alert.jsx @@ -0,0 +1,81 @@ +import React, { Component } from "react"; +import styled from "styled-components"; +import { string, number, func } from "prop-types"; +import { Grid, Row, Col } from "react-bootstrap"; + +const Wrapper = styled.div` + margin-bottom: 0; +`; + +class Alert extends Component { + static alertClass(type) { + const classes = { + error: "alert-danger", + alert: "alert-warning", + notice: "alert-info", + success: "alert-success" + }; + return classes[type] || classes.success; + } + + static glyphiconClass(type) { + const classes = { + error: "glyphicon-exclamation-sign", + alert: "glyphicon-exclamation-sign", + notice: "glyphicon-info-sign", + success: "glyphicon-ok-sign" + }; + return classes[type] || classes.success; + } + + componentDidMount() { + this.timer = setTimeout( + this.props.onClose, + this.props.timeout + ); + } + + componentWillUnmount() { + clearTimeout(this.timer); + } + + render() { + const alertClassName = + `alert + ${Alert.alertClass(this.props.type)} + alert-dismissable + alert-floating`; + const glyphiconClassName = + `glyphicon + ${Alert.glyphiconClass(this.props.type)}`; + + return ( + + + + + + × + + + {this.props.message} + + + + + ); + } +} + +Alert.propTypes = { + message: string.isRequired, + type: string.isRequired, + timeout: number.isRequired, + onClose: func.isRequired +}; + +export default Alert; \ No newline at end of file diff --git a/app/javascript/src/components/AlertsContainer/index.jsx b/app/javascript/src/components/AlertsContainer/index.jsx new file mode 100644 index 000000000..0577b1ad0 --- /dev/null +++ b/app/javascript/src/components/AlertsContainer/index.jsx @@ -0,0 +1,65 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import styled from "styled-components"; +import TransitionGroup from 'react-transition-group/TransitionGroup'; +import CSSTransition from 'react-transition-group/CSSTransition'; +import { shape, arrayOf, string, number, func } from "prop-types"; +import { clearAlert } from "../actions/AlertsActions"; +import Alert from "./components/Alert"; + +const Wrapper = styled.div` + position: absolute; + z-index: 1000; + width: 100%; +`; + +class AlertsContainer extends Component { + constructor(props) { + super(props); + + this.renderAlert = this.renderAlert.bind(this); + } + + renderAlert(alert) { + return ( + this.props.clearAlert(alert.id)} + /> + ); + } + + render() { + return ( + + + {this.props.alerts.map((alert) => + + {this.renderAlert(alert)} + + )} + + + ); + } +} + +AlertsContainer.propTypes = { + alerts: arrayOf( + shape({ + message: string.isRequired, + type: string.isRequired, + id: string.isRequired, + timeout: number, + onClose: func + }).isRequired + ).isRequired, + clearAlert: func.isRequired +} + +const mapStateToProps = ({ alerts }) => ({ alerts }); + +export default connect(mapStateToProps, { clearAlert })(AlertsContainer); \ No newline at end of file diff --git a/app/javascript/src/components/InviteUsersModal/index.jsx b/app/javascript/src/components/InviteUsersModal/index.jsx index a6a82a0d2..b0f63439b 100644 --- a/app/javascript/src/components/InviteUsersModal/index.jsx +++ b/app/javascript/src/components/InviteUsersModal/index.jsx @@ -55,7 +55,7 @@ class InviteUsersModal extends Component { team_id: this.props.team.id }) .then(({ data }) => { - this.setState({ inviteResults: data, showInviteUsersResults: true}); + this.setState({ inviteResults: data, showInviteUsersResults: true }); }) .catch(error => { console.log("Invite As Error: ", error); @@ -89,17 +89,23 @@ class InviteUsersModal extends Component { {modalBody} - - - {inviteButton} + + + @@ -110,10 +116,7 @@ class InviteUsersModal extends Component { InviteUsersModal.propTypes = { showModal: bool.isRequired, onCloseModal: func.isRequired, - team: shape({ - id: number.isRequired, - name: string.isRequired - }).isRequired, + team: shape({ id: number.isRequired, name: string.isRequired }).isRequired, updateUsersCallback: func.isRequired }; diff --git a/app/javascript/src/components/Navigation/index.jsx b/app/javascript/src/components/Navigation/index.jsx index 6fe32f058..5cc2c5e9a 100644 --- a/app/javascript/src/components/Navigation/index.jsx +++ b/app/javascript/src/components/Navigation/index.jsx @@ -20,6 +20,7 @@ import UserAccountDropdown from "./components/UserAccountDropdown"; const StyledNavbar = styled(Navbar)` background-color: ${WHITE_COLOR}; border-color: ${BORDER_GRAY_COLOR}; + margin-bottom: 0; `; const StyledBrand = styled.a` diff --git a/app/javascript/src/components/actions/AlertsActions.js b/app/javascript/src/components/actions/AlertsActions.js new file mode 100644 index 000000000..f6ff5b175 --- /dev/null +++ b/app/javascript/src/components/actions/AlertsActions.js @@ -0,0 +1,29 @@ +import shortid from "shortid"; +import { + ADD_ALERT, + CLEAR_ALERT, + CLEAR_ALL_ALERTS +} from "../../config/action_types"; + +export function addAlert(message, + type, + id = shortid.generate(), + timeout = 5000) { + return { + payload: { + message, + type, + id, + timeout + }, + type: ADD_ALERT + }; +} + +export function clearAlert(id) { + return { payload: id, type: CLEAR_ALERT } +} + +export function clearAllAlerts() { + return { type: CLEAR_ALL_ALERTS }; +} \ No newline at end of file diff --git a/app/javascript/src/components/reducers/AlertsReducers.js b/app/javascript/src/components/reducers/AlertsReducers.js new file mode 100644 index 000000000..098459ca6 --- /dev/null +++ b/app/javascript/src/components/reducers/AlertsReducers.js @@ -0,0 +1,31 @@ +import { + ADD_ALERT, + CLEAR_ALERT, + CLEAR_ALL_ALERTS +} from "../../config/action_types"; + +export const alerts = ( + state = [], + action +) => { + switch(action.type) { + case ADD_ALERT: + return [ + ...state, + { + message: action.payload.message, + type: action.payload.type, + id: action.payload.id, + timeout: action.payload.timeout + } + ]; + case CLEAR_ALERT: + return state.filter((alert) => ( + alert.id !== action.payload + )); + case CLEAR_ALL_ALERTS: + return []; + default: + return state; + } +}; \ No newline at end of file diff --git a/app/javascript/src/config/action_types.js b/app/javascript/src/config/action_types.js index b2d3a7443..732a81db4 100644 --- a/app/javascript/src/config/action_types.js +++ b/app/javascript/src/config/action_types.js @@ -35,3 +35,8 @@ export const UPDATE_TEAM_DESCRIPTION_MODAL = "UPDATE_TEAM_DESCRIPTION_MODAL"; // spinner export const SPINNER_ON = "SPINNER_ON"; export const SPINNER_OFF = "SPINNER_OFF"; + +// alerts +export const ADD_ALERT = "ADD_ALERT"; +export const CLEAR_ALERT = "CLEAR_ALERT"; +export const CLEAR_ALL_ALERTS = "CLEAR_ALL_ALERTS"; \ No newline at end of file diff --git a/app/javascript/src/config/reducers.js b/app/javascript/src/config/reducers.js index 2f00797ea..5561d4cb3 100644 --- a/app/javascript/src/config/reducers.js +++ b/app/javascript/src/config/reducers.js @@ -6,11 +6,13 @@ import { } from "../components/reducers/TeamReducers"; import { globalActivities } from "../components/reducers/ActivitiesReducers"; import { currentUser } from "../components/reducers/UsersReducer"; +import { alerts } from "../components/reducers/AlertsReducers"; export default combineReducers({ current_team: setCurrentTeam, all_teams: getListOfTeams, global_activities: globalActivities, current_user: currentUser, - showLeaveTeamModal + showLeaveTeamModal, + alerts }); diff --git a/app/javascript/src/index.jsx b/app/javascript/src/index.jsx index 23fbf1cb5..1328b9fca 100644 --- a/app/javascript/src/index.jsx +++ b/app/javascript/src/index.jsx @@ -3,11 +3,13 @@ import { BrowserRouter } from "react-router-dom"; import { Provider } from "react-redux"; import { IntlProvider, addLocaleData } from "react-intl"; import enLocaleData from "react-intl/locale-data/en"; +import styled from "styled-components"; import { flattenMessages } from "./config/locales/utils"; import messages from "./config/locales/messages"; import store from "./config/store"; import Spinner from "./components/Spinner"; +import AlertsContainer from "./components/AlertsContainer"; import ModalsContainer from "./components/ModalsContainer"; import SettingsPage from "./scenes/SettingsPage"; import Navigation from "./components/Navigation"; @@ -15,14 +17,22 @@ import Navigation from "./components/Navigation"; addLocaleData([...enLocaleData]); const locale = "en-US"; -export default () => +const ContentWrapper = styled.div` + margin-top: 15px; +`; + +const ScinoteApp = () => - + - + + + + @@ -31,3 +41,5 @@ export default () => ; + +export default ScinoteApp; diff --git a/app/javascript/src/styles/animations.scss b/app/javascript/src/styles/animations.scss new file mode 100644 index 000000000..32a90a5a4 --- /dev/null +++ b/app/javascript/src/styles/animations.scss @@ -0,0 +1,30 @@ +.alert-animated-enter { + opacity: .01; + + &.alert-animated-enter-active { + opacity: 1; + transition: opacity 150ms ease-in; + } +} + +.alert-animated-exit { + opacity: 1; + padding-bottom: 15px; + padding-top: 15px; + margin-bottom: 20px; + height: 50px; + + &.alert-animated-exit-active { + overflow: hidden; + opacity: .01; + padding-top: 0; + padding-bottom: 0; + margin-bottom: 0; + height: 0; + transition: opacity 300ms ease-in, + padding-top 500ms ease-in, + padding-bottom 500ms ease-in, + margin-bottom 500ms ease-in, + height 500ms ease-in; + } +} diff --git a/app/javascript/src/styles/main.scss b/app/javascript/src/styles/main.scss index 434b3392b..c4dc7cf78 100644 --- a/app/javascript/src/styles/main.scss +++ b/app/javascript/src/styles/main.scss @@ -1,4 +1,5 @@ @import 'constants'; +@import 'animations'; @import 'react-bootstrap-timezone-picker/dist/react-bootstrap-timezone-picker.min.css'; @import '~react-bootstrap-table/dist/react-bootstrap-table.min'; @import 'react-tagsinput/react-tagsinput.css'; @@ -14,6 +15,10 @@ body { background-color: $color-theme-primary; } +.btn { + border-radius: 1.5em; +} + .btn-primary { background-color: $color-theme-secondary; border-color: $primary-hover-color; @@ -30,3 +35,23 @@ body { position: relative !important; } } + +// tags input +.react-tagsinput--focused { + border-color: $color-theme-primary; +} + +.react-tagsinput-input { + width: 100%; +} + +.react-tagsinput-tag { + background-color: $color-theme-primary; + border: 0; + color: $color-white; + font-weight: bold; +} + +.react-tagsinput-remove { + color: $color-white; +} diff --git a/package.json b/package.json index 6a3085177..b71c0a2d6 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,11 @@ "react-bootstrap-table": "^4.0.0", "react-bootstrap-timezone-picker": "^1.0.11", "react-data-grid": "^2.0.2", +<<<<<<< HEAD +======= + "react-tagsinput": "^3.17.0", + "react-transition-group": "^2.2.0", +>>>>>>> fcea55c2a102470bd4520ea0ac1ef771710c17d8 "react-dom": "^15.6.1", "react-intl": "^2.3.0", "react-intl-redux": "^0.6.0", @@ -82,6 +87,7 @@ "redux-thunk": "^2.2.0", "resolve-url-loader": "^2.1.0", "sass-loader": "^6.0.6", + "shortid": "^2.2.8", "style-loader": "^0.18.2", "styled-components": "^2.1.1", "webpack": "^3.2.0", diff --git a/spec/controllers/client_api/teams/teams_controller_spec.rb b/spec/controllers/client_api/teams/teams_controller_spec.rb index 9ec6ba221..2ffd1484b 100644 --- a/spec/controllers/client_api/teams/teams_controller_spec.rb +++ b/spec/controllers/client_api/teams/teams_controller_spec.rb @@ -6,8 +6,10 @@ describe ClientApi::Teams::TeamsController, type: :controller do 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' + @team_one = FactoryGirl.create :team, created_by: @user_one + @team_two = FactoryGirl.create :team, + name: 'Team two', + created_by: @user_two FactoryGirl.create :user_team, team: @team_one, user: @user_one, role: 2 end @@ -19,6 +21,45 @@ describe ClientApi::Teams::TeamsController, type: :controller do end end + describe 'POST #create' do + before do + @team_one.update_attribute(:name, 'My Team') + @team_one.update_attribute(:description, 'Lorem ipsum ipsum') + end + + it 'should return HTTP success response' do + post :create, params: { team: @team_one }, as: :json + expect(response).to have_http_status(:ok) + end + + it 'should return HTTP unprocessable_entity response if name too short' do + @team_one.update_attribute( + :name, + ('a' * (Constants::NAME_MIN_LENGTH - 1)).to_s + ) + post :create, params: { team: @team_one }, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'should return HTTP unprocessable_entity response if name too long' do + @team_one.update_attribute( + :name, + ('a' * (Constants::NAME_MAX_LENGTH + 1)).to_s + ) + post :create, params: { team: @team_one }, as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'should return HTTP unprocessable_entity response if description too long' do + @team_one.update_attribute( + :description, + ('a' * (Constants::TEXT_MAX_LENGTH + 1)).to_s + ) + post :create, params: { team: @team_one }, as: :json + expect(response).to have_http_status(:unprocessable_entity) + 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 diff --git a/spec/services/client_api/teams/create_service_spec.rb b/spec/services/client_api/teams/create_service_spec.rb new file mode 100644 index 000000000..57207bc1d --- /dev/null +++ b/spec/services/client_api/teams/create_service_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' + +include ClientApi::Teams + +describe ClientApi::Teams::CreateService do + let(:user) { create :user, email: 'user@asdf.com' } + let(:team) do + build :team, name: 'My Team', description: 'My Description' + end + + it 'should raise a StandardError if current_user is not assigned' do + expect { CreateService.new }.to raise_error(StandardError) + end + + it 'should create a new team' do + service = CreateService.new( + current_user: user, + params: { name: team.name, description: team.description } + ) + result = service.execute + expect(result[:status]).to eq :success + + team_n = Team.order(created_at: :desc).first + expect(team_n.name).to eq team.name + expect(team_n.description).to eq team.description + expect(team_n.created_by).to eq user + expect(team_n.users.count).to eq 1 + expect(team_n.users.take).to eq user + end + + it 'should return error response if params = {}' do + service = CreateService.new(current_user: user, params: {}) + result = service.execute + expect(result[:status]).to eq :error + end + + it 'should return error response if params are missing :name attribute' do + service = CreateService.new( + current_user: user, + params: { description: team.description } + ) + result = service.execute + expect(result[:status]).to eq :error + end + + it 'should return error response if name too short' do + team.name = ('a' * (Constants::NAME_MIN_LENGTH - 1)).to_s + service = CreateService.new( + current_user: user, + params: { name: team.name, description: team.description } + ) + result = service.execute + expect(result[:status]).to eq :error + end + + it 'should return error response if name too long' do + team.name = ('a' * (Constants::NAME_MAX_LENGTH + 1)).to_s + service = CreateService.new( + current_user: user, + params: { name: team.name, description: team.description } + ) + result = service.execute + expect(result[:status]).to eq :error + end + + it 'should return error response if description too long' do + team.description = ('a' * (Constants::TEXT_MAX_LENGTH + 1)).to_s + service = CreateService.new( + current_user: user, + params: { name: team.name, description: team.description } + ) + result = service.execute + expect(result[:status]).to eq :error + end +end diff --git a/yarn.lock b/yarn.lock index 61840a4cc..062e042d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,6 +1156,10 @@ center-align@^0.1.1: align-text "^0.1.3" lazy-cache "^1.0.3" +chain-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc" + chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -4825,6 +4829,17 @@ react-timezone@^0.2.0: dependencies: classnames "^2.2.1" +react-transition-group@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.0.tgz#793bf8cb15bfe91b3101b24bce1c1d2891659575" + dependencies: + chain-function "^1.0.0" + classnames "^2.2.5" + dom-helpers "^3.2.0" + loose-envify "^1.3.1" + prop-types "^15.5.8" + warning "^3.0.0" + react@^15.6.1: version "15.6.1" resolved "https://registry.yarnpkg.com/react/-/react-15.6.1.tgz#baa8434ec6780bde997cdc380b79cd33b96393df" @@ -5298,6 +5313,10 @@ shelljs@^0.7.5: interpret "^1.0.0" rechoir "^0.6.2" +shortid@^2.2.8: + version "2.2.8" + resolved "https://registry.yarnpkg.com/shortid/-/shortid-2.2.8.tgz#033b117d6a2e975804f6f0969dbe7d3d0b355131" + signal-exit@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"