diff --git a/.babelrc b/.babelrc index a78ea783a..44f0cbcce 100644 --- a/.babelrc +++ b/.babelrc @@ -18,6 +18,7 @@ "plugins": [ "transform-object-rest-spread", "syntax-dynamic-import", + "transform-react-jsx-source", [ "transform-class-properties", { diff --git a/app/controllers/client_api/notifications_controller.rb b/app/controllers/client_api/notifications_controller.rb index 3985813ed..07bfd7230 100644 --- a/app/controllers/client_api/notifications_controller.rb +++ b/app/controllers/client_api/notifications_controller.rb @@ -1,23 +1,28 @@ module ClientApi class NotificationsController < ApplicationController - before_action :last_notifications, only: :recent_notifications - def recent_notifications respond_to do |format| format.json do render template: '/client_api/notifications/index', status: :ok, - locals: { notifications: @recent_notifications } + locals: { + notifications: + UserNotification.recent_notifications(current_user) + } + end + end + # clean the unseen notifications + UserNotification.seen_by_user(current_user) + end + + def unread_notifications_count + respond_to do |format| + format.json do + render json: { + count: UserNotification.unseen_notification_count(current_user) + }, status: :ok end end end - - private - - def last_notifications - @recent_notifications = - UserNotification.recent_notifications(current_user) - UserNotification.seen_by_user(current_user) - end end end diff --git a/app/controllers/client_api/users/users_controller.rb b/app/controllers/client_api/users/users_controller.rb index 1930e7f49..98c663c40 100644 --- a/app/controllers/client_api/users/users_controller.rb +++ b/app/controllers/client_api/users/users_controller.rb @@ -2,6 +2,16 @@ module ClientApi module Users class UsersController < ApplicationController + def sign_out_user + respond_to do |format| + if sign_out current_user + format.json { render json: {}, status: :ok } + else + format.json { render json: {}, status: :unauthorized } + end + end + end + def preferences_info settings = current_user.settings respond_to do |format| diff --git a/app/javascript/src/components/Navigation/components/NotificationsDropdown.jsx b/app/javascript/src/components/Navigation/components/NotificationsDropdown.jsx index 334b8eca8..d63e1f47c 100644 --- a/app/javascript/src/components/Navigation/components/NotificationsDropdown.jsx +++ b/app/javascript/src/components/Navigation/components/NotificationsDropdown.jsx @@ -1,15 +1,21 @@ import React, { Component } from "react"; +import { Link } from "react-router-dom"; import { NavDropdown } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; -import axios from "axios"; import styled from "styled-components"; -import { RECENT_NOTIFICATIONS_PATH } from "../../../config/routes"; +import { + getRecentNotifications, + getUnreadNotificationsCount +} from "../../../services/api/notifications_api"; import { MAIN_COLOR_BLUE, WILD_SAND_COLOR, MYSTIC_COLOR } from "../../../config/constants/colors"; +import { + SETTINGS_ACCOUNT_PREFERENCES +} from "../../../config/routes" import NotificationItem from "./NotificationItem"; import Spinner from "../../Spinner"; @@ -21,7 +27,9 @@ const StyledListHeader = styled(CustomNavItem)` font-weight: bold; padding: 8px; - & a, a:hover, a:active { + & a, + a:hover, + a:active { color: ${WILD_SAND_COLOR}; } `; @@ -44,40 +52,81 @@ const StyledNavDropdown = styled(NavDropdown)` } `; +const StyledSpan = styled.span` + background-color: ${MAIN_COLOR_BLUE}; + border-radius: 5px; + color: ${WILD_SAND_COLOR}; + font-size: 11px; + font-weight: bold; + margin-left: 12px; + padding: 1px 6px; + right: 19px; + top: 3px; + position: relative; +`; + class NotificationsDropdown extends Component { constructor(props) { super(props); - this.state = { notifications: [] }; + this.state = { + notifications: [], + notificationsCount: 0 + }; this.getRecentNotifications = this.getRecentNotifications.bind(this); this.renderNotifications = this.renderNotifications.bind(this); + this.renderNotificationStatus = this.renderNotificationStatus.bind(this); + this.loadStatus = this.loadStatus.bind(this); + } + + componentWillMount() { + this.loadStatus(); + } + + componentDidMount() { + const minutes = 60 * 1000; + setInterval(this.loadStatus, minutes); } getRecentNotifications(e) { e.preventDefault(); - axios - .get(RECENT_NOTIFICATIONS_PATH, { withCredentials: true }) - .then(({ data }) => { - this.setState({ notifications: data }); - }) + getRecentNotifications() + .then(response => + this.setState({ notifications: response, notificationsCount: 0 }) + ) .catch(error => { console.log("get Notifications Error: ", error); // TODO change this }); } + loadStatus() { + getUnreadNotificationsCount().then(response => { + this.setState({ notificationsCount: parseInt(response.count, 10) }); + }); + } + renderNotifications() { - const list = this.state.notifications.map(notification => + const list = this.state.notifications.map(notification => ( - ); + )); const items = - this.state.notifications.length > 0 - ? list - : - - ; + this.state.notifications.length > 0 ? ( + list + ) : ( + + + + ); return items; } + renderNotificationStatus() { + if (this.state.notificationsCount > 0) { + return {this.state.notificationsCount}; + } + return ; + } + render() { return ( + {this.renderNotificationStatus()} } onClick={this.getRecentNotifications} @@ -98,9 +148,9 @@ class NotificationsDropdown extends Component { - + - + {this.renderNotifications()} diff --git a/app/javascript/src/components/Navigation/components/TeamSwitch.jsx b/app/javascript/src/components/Navigation/components/TeamSwitch.jsx index 1e8f4eed0..7ee9259ee 100644 --- a/app/javascript/src/components/Navigation/components/TeamSwitch.jsx +++ b/app/javascript/src/components/Navigation/components/TeamSwitch.jsx @@ -6,6 +6,7 @@ import { NavDropdown, MenuItem, Glyphicon } from "react-bootstrap"; import styled from "styled-components"; import _ from "lodash"; +import { ROOT_PATH } from "../../../config/routes"; import { BORDER_GRAY_COLOR } from "../../../config/constants/colors"; import { changeTeam } from "../../actions/TeamsActions"; import { getTeamsList } from "../../actions/TeamsActions"; @@ -27,15 +28,18 @@ class TeamSwitch extends Component { changeTeam(teamId) { this.props.changeTeam(teamId); + window.location = ROOT_PATH; } displayTeams() { if (!_.isEmpty(this.props.all_teams)) { - return this.props.all_teams.filter(team => !team.current_team).map(team => - this.changeTeam(team.id)} key={team.id}> - {team.name} - - ); + return this.props.all_teams + .filter(team => !team.current_team) + .map(team => ( + this.changeTeam(team.id)} key={team.id}> + {team.name} + + )); } } diff --git a/app/javascript/src/components/Navigation/components/UserAccountDropdown.jsx b/app/javascript/src/components/Navigation/components/UserAccountDropdown.jsx index 527324d17..d2528e0f3 100644 --- a/app/javascript/src/components/Navigation/components/UserAccountDropdown.jsx +++ b/app/javascript/src/components/Navigation/components/UserAccountDropdown.jsx @@ -1,12 +1,13 @@ import React, { Component } from "react"; import { connect } from "react-redux"; -import PropTypes from "prop-types"; +import { func, shape, string, number } from "prop-types"; import { NavDropdown, MenuItem, Image } from "react-bootstrap"; import styled from "styled-components"; import { FormattedMessage } from "react-intl"; +import { SIGN_IN_PATH } from "../../../config/routes"; -import { getCurrentUser } from "../../../services/api/users_api"; -import { addCurrentUser } from "../../actions/UsersActions"; +import { addCurrentUser, destroyState } from "../../actions/UsersActions"; +import { signOutUser, getCurrentUser } from "../../../services/api/users_api"; const StyledNavDropdown = styled(NavDropdown)` & #user-account-dropdown { @@ -25,6 +26,15 @@ class UserAccountDropdown extends Component { getCurrentUser().then(data => { this.props.addCurrentUser(data); }); + this.signOut = this.signOut.bind(this); + } + + signOut() { + document.querySelector('meta[name="csrf-token"]').remove(); + signOutUser().then(() => { + this.props.destroyState(); + window.location = SIGN_IN_PATH; + }); } render() { @@ -52,7 +62,7 @@ class UserAccountDropdown extends Component { - + @@ -61,17 +71,18 @@ class UserAccountDropdown extends Component { } UserAccountDropdown.propTypes = { - addCurrentUser: PropTypes.func.isRequired, - current_user: PropTypes.shape({ - id: PropTypes.number.isRequired, - fullName: PropTypes.string.isRequired, - avatarThumb: PropTypes.string.isRequired + addCurrentUser: func.isRequired, + destroyState: func.isRequired, + current_user: shape({ + id: number.isRequired, + fullName: string.isRequired, + avatarThumb: string.isRequired }).isRequired }; // Map the states from store to component const mapStateToProps = ({ current_user }) => ({ current_user }); -export default connect(mapStateToProps, { addCurrentUser })( +export default connect(mapStateToProps, { destroyState, addCurrentUser })( UserAccountDropdown ); diff --git a/app/javascript/src/components/actions/UsersActions.js b/app/javascript/src/components/actions/UsersActions.js index c1556280c..7bb848ec3 100644 --- a/app/javascript/src/components/actions/UsersActions.js +++ b/app/javascript/src/components/actions/UsersActions.js @@ -1,8 +1,8 @@ -import axios from "../../config/axios"; +import { USER_LOGOUT, SET_CURRENT_USER } from "../../config/action_types"; -import { - SET_CURRENT_USER, -} from "../../config/action_types"; +export function destroyState() { + return { type: USER_LOGOUT }; +} export function addCurrentUser(data) { return { diff --git a/app/javascript/src/config/action_types.js b/app/javascript/src/config/action_types.js index 58ba2db61..2ae8f4162 100644 --- a/app/javascript/src/config/action_types.js +++ b/app/javascript/src/config/action_types.js @@ -4,6 +4,7 @@ export const GET_LIST_OF_TEAMS = "GET_LIST_OF_TEAMS"; export const SET_TEAM_DETAILS = "SET_TEAM_DETAILS"; // users +export const USER_LOGOUT = "USER_LOGOUT"; export const SET_CURRENT_USER = "SET_CURRENT_USER"; // user teams diff --git a/app/javascript/src/config/axios.js b/app/javascript/src/config/axios.js index 7cf8426c7..b514b7001 100644 --- a/app/javascript/src/config/axios.js +++ b/app/javascript/src/config/axios.js @@ -1,7 +1,19 @@ +// @TODO remove this file ASAP the preferences/profile refactoring is merged import axios from "axios"; +import store from "./store"; +import { SIGN_IN_PATH } from "./routes"; +import { destroyState } from "../components/actions/UsersActions"; export default axios.create({ + withCredentials: true, headers: { "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content + }, + validateStatus(status) { + if (status === 401) { + store.dispatch(destroyState); + window.location = SIGN_IN_PATH; + } + return status >= 200 && status < 300; } }); diff --git a/app/javascript/src/config/reducers.js b/app/javascript/src/config/reducers.js index d18a2c598..1f7cbc866 100644 --- a/app/javascript/src/config/reducers.js +++ b/app/javascript/src/config/reducers.js @@ -1,16 +1,26 @@ import { combineReducers } from "redux"; +import { USER_LOGOUT } from "./action_types"; import { setCurrentTeam, getListOfTeams, - showLeaveTeamModal, + showLeaveTeamModal } from "../components/reducers/TeamReducers"; import { currentUser } from "../components/reducers/UsersReducer"; import { alerts } from "../components/reducers/AlertsReducers"; -export default combineReducers({ +const appReducer = combineReducers({ current_team: setCurrentTeam, all_teams: getListOfTeams, current_user: currentUser, showLeaveTeamModal, alerts }); + +const rootReducer = (state, action) => { + if (action.type === USER_LOGOUT) { + state = undefined; + } + return appReducer(state, action); +}; + +export default rootReducer; diff --git a/app/javascript/src/config/routes.js b/app/javascript/src/config/routes.js index 94c4c24eb..74abccff0 100644 --- a/app/javascript/src/config/routes.js +++ b/app/javascript/src/config/routes.js @@ -1,8 +1,9 @@ export const ROOT_PATH = "/"; +export const SIGN_IN_PATH = "/users/sign_in"; // Settings page export const SETTINGS_TEAMS_ROUTE = "/settings/teams"; export const SETTINGS_TEAM_ROUTE = "/settings/teams/:id"; export const SETTINGS_NEW_TEAM_ROUTE = "/settings/teams/new"; export const SETTINGS_ACCOUNT_PROFILE = "/settings/account/profile"; -export const SETTINGS_ACCOUNT_PREFERENCES = "/settings/account/preferences"; \ No newline at end of file +export const SETTINGS_ACCOUNT_PREFERENCES = "/settings/account/preferences"; diff --git a/app/javascript/src/services/api/config.js b/app/javascript/src/services/api/config.js index 86f47cb97..36cef2904 100644 --- a/app/javascript/src/services/api/config.js +++ b/app/javascript/src/services/api/config.js @@ -1,8 +1,20 @@ import axios from "axios"; +import store from "../../config/store"; +import { SIGN_IN_PATH } from "../../config/routes"; +import { destroyState } from "../../components/actions/UsersActions"; export const axiosInstance = axios.create({ withCredentials: true, headers: { "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content + }, + validateStatus(status) { + if (status === 401) { + setTimeout(() => { + store.dispatch(destroyState) + window.location = SIGN_IN_PATH; + }, 500); + } + return status >= 200 && status < 300; } }); diff --git a/app/javascript/src/services/api/endpoints.js b/app/javascript/src/services/api/endpoints.js index 66eb648c0..efcc0f259 100644 --- a/app/javascript/src/services/api/endpoints.js +++ b/app/javascript/src/services/api/endpoints.js @@ -1,3 +1,8 @@ +// notifications +export const RECENT_NOTIFICATIONS_PATH = "/client_api/recent_notifications"; +export const UNREADED_NOTIFICATIONS_PATH = + "/client_api/unread_notifications_count"; + // activities export const ACTIVITIES_PATH = "/client_api/activities"; @@ -22,6 +27,7 @@ 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" // info dropdown_title export const CUSTOMER_SUPPORT_LINK = "http://scinote.net/support"; diff --git a/app/javascript/src/services/api/notifications_api.js b/app/javascript/src/services/api/notifications_api.js new file mode 100644 index 000000000..a724a44f0 --- /dev/null +++ b/app/javascript/src/services/api/notifications_api.js @@ -0,0 +1,15 @@ +import { axiosInstance } from "./config"; +import { + RECENT_NOTIFICATIONS_PATH, + UNREADED_NOTIFICATIONS_PATH +} from "./endpoints"; + +export const getRecentNotifications = () => { + return axiosInstance.get(RECENT_NOTIFICATIONS_PATH).then(({ data }) => data); +}; + +export const getUnreadNotificationsCount = () => { + return axiosInstance + .get(UNREADED_NOTIFICATIONS_PATH) + .then(({ data }) => data); +}; diff --git a/app/javascript/src/services/api/users_api.js b/app/javascript/src/services/api/users_api.js index 81f9c2016..f748a77e8 100644 --- a/app/javascript/src/services/api/users_api.js +++ b/app/javascript/src/services/api/users_api.js @@ -4,7 +4,8 @@ import { UPDATE_USER_PATH, CURRENT_USER_PATH, PREFERENCES_INFO_PATH, - STATISTICS_INFO_PATH + STATISTICS_INFO_PATH, + SIGN_OUT_PATH } from "./endpoints"; export const getUserProfileInfo = () => @@ -29,3 +30,5 @@ export const getCurrentUser = () => export const getStatisticsInfo = () => axiosInstance.get(STATISTICS_INFO_PATH).then(({ data }) => data.user); + +export const signOutUser = () => axiosInstance.get(SIGN_OUT_PATH); diff --git a/config/routes/notifications.rb b/config/routes/notifications.rb index dac1014d8..96e70b528 100644 --- a/config/routes/notifications.rb +++ b/config/routes/notifications.rb @@ -1,2 +1,4 @@ # notifications get '/recent_notifications', to: 'notifications#recent_notifications' +get '/unread_notifications_count', + to: 'notifications#unread_notifications_count' diff --git a/config/routes/users.rb b/config/routes/users.rb index 536efa443..02f0453a1 100644 --- a/config/routes/users.rb +++ b/config/routes/users.rb @@ -2,6 +2,7 @@ get '/current_user_info', to: 'users/users#current_user_info' namespace :users do + get '/sign_out_user', to: 'users#sign_out_user' delete '/remove_user', to: 'user_teams#remove_user' delete '/leave_team', to: 'user_teams#leave_team' put '/update_role', to: 'user_teams#update_role' diff --git a/package.json b/package.json index b3b3dc251..2f6411d81 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "babel-eslint": "^8.0.1", + "babel-plugin-transform-react-jsx-source": "^6.22.0", "eslint": "^4.7.2", "eslint-config-airbnb": "^15.1.0", "eslint-config-google": "^0.9.1", diff --git a/spec/controllers/client_api/notifications_controller_spec.rb b/spec/controllers/client_api/notifications_controller_spec.rb new file mode 100644 index 000000000..3801f1d3f --- /dev/null +++ b/spec/controllers/client_api/notifications_controller_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +describe ClientApi::NotificationsController, type: :controller do + login_user + let(:notification) { create :notification } + let(:user_notification) do + create :user_notification, + user: User.first, + notification: notification + end + + describe '#recent_notifications' do + it 'returns a list of notifications' do + get :recent_notifications, format: :json + expect(response).to be_success + expect(response).to render_template('client_api/notifications/index') + end + end + + describe '#unreaded_notifications_number' do + it 'returns a number of unreaded notifications' do + get :unread_notifications_count, format: :json + expect(response).to be_success + expect(response.body).to include('count') + end + end +end diff --git a/spec/controllers/client_api/users/users_controller_spec.rb b/spec/controllers/client_api/users/users_controller_spec.rb index f6fbed1af..b3cb58c37 100644 --- a/spec/controllers/client_api/users/users_controller_spec.rb +++ b/spec/controllers/client_api/users/users_controller_spec.rb @@ -8,6 +8,20 @@ describe ClientApi::Users::UsersController, type: :controller do @user = User.first end + describe '#sign_out_user' do + it 'returns unauthorized response' do + sign_out @user + get :sign_out_user, format: :json + expect(response).to have_http_status(:unauthorized) + end + + it 'responds successfully if the user is signed out' do + get :sign_out_user, format: :json + expect(response).to have_http_status(:ok) + expect(subject.current_user).to eq(nil) + end + end + describe 'GET current_user_info' do it 'responds successfully' do get :current_user_info, format: :json diff --git a/spec/factories/notifications.rb b/spec/factories/notifications.rb new file mode 100644 index 000000000..f92723cf6 --- /dev/null +++ b/spec/factories/notifications.rb @@ -0,0 +1,8 @@ +FactoryGirl.define do + factory :notification do + title 'Admin was added as Owner to project ' \ + 'Demo project - qPCR by User.' + message 'Project: Demo project - qPCR' + type_of 'assignment' + end +end diff --git a/spec/factories/user_notification.rb b/spec/factories/user_notification.rb new file mode 100644 index 000000000..9341a28f2 --- /dev/null +++ b/spec/factories/user_notification.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :user_notification do + checked false + end +end diff --git a/spec/models/user_notification_spec.rb b/spec/models/user_notification_spec.rb index 70eb9445f..a7ba560f2 100644 --- a/spec/models/user_notification_spec.rb +++ b/spec/models/user_notification_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe UserNotification, type: :model do + let(:user) { create :user } + it 'should be of class UserNotification' do expect(subject.class).to eq UserNotification end @@ -17,4 +19,37 @@ describe UserNotification, type: :model do it { should belong_to :user } it { should belong_to :notification } end + + describe '#unseen_notification_count ' do + let(:notifcation) { create :notification } + it 'returns a number of unseen notifications' do + create :user_notification, user: user, notification: notifcation + expect(UserNotification.unseen_notification_count(user)).to eq 1 + end + end + + describe '#recent_notifications' do + let(:notifcation_one) { create :notification } + let(:notifcation_two) { create :notification } + + it 'returns a list of notifications ordered by created_at DESC' do + create :user_notification, user: user, notification: notifcation_one + create :user_notification, user: user, notification: notifcation_two + notifications = UserNotification.recent_notifications(user) + expect(notifications).to eq [notifcation_two, notifcation_one] + end + end + + describe '#seen_by_user' do + let!(:notification) { create :notification } + let!(:user_notification_one) do + create :user_notification, user: user, notification: notification + end + + it 'set the check status to false' do + expect { + UserNotification.seen_by_user(user) + }.to change { user_notification_one.reload.checked }.from(false).to(true) + end + end end