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 =>
-
- );
+ return this.props.all_teams
+ .filter(team => !team.current_team)
+ .map(team => (
+
+ ));
}
}
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