header notifications

show number of unread notifiacation count on header
allow user to scroll through the notification in header notificaton
drawer
This commit is contained in:
Nuwan 2024-09-03 00:11:36 +05:30
parent 727147ffee
commit 499cd7e16b
25 changed files with 280 additions and 140 deletions

View File

@ -8,8 +8,6 @@ describe('Top Navigation', () => {
const showProfileDropdown = () => {
cy.get('[data-testid=navbarTopProfileDropdown]').should('exist');
cy.contains('Peter Pan').should('exist');
//cy.contains("My Profile").should('exist')
cy.contains('Sign Out').should('exist');
};
@ -21,8 +19,7 @@ describe('Top Navigation', () => {
it('shows homepage', () => {
cy.visit('/');
cy.wait('@getAppFeatures');
cy.contains('Home').should('exist');
showSubscribeToUpdates();
cy.contains('Sign in').should('exist');
});
it('not allowed to protected page', () => {
@ -49,21 +46,20 @@ describe('Top Navigation', () => {
it('sign out', () => {
cy.get('[data-testid=navbarTopProfileDropdown]')
.contains('Peter Pan')
.click();
cy.stubUnauthenticate();
cy.get('[data-testid=navbarTopProfileDropdown]')
.contains('Sign Out')
.click();
cy.get('[data-testid=navbarTopProfileDropdown]').should('not.exist');
cy.contains('Home');
cy.contains('Sign in');
});
});
describe('header notifications', () => {
beforeEach(() => {
cy.stubAuthenticate();
cy.intercept('GET', /\S+\/notifications/, { fixture: 'notifications' });
cy.intercept('GET', /\S+\/my_notifications/, { fixture: 'notifications' });
cy.intercept('GET', /\S+\/profile\S+/, { fixture: 'person' });
cy.visit('/');
cy.wait('@getAppFeatures');
@ -71,7 +67,8 @@ describe('Top Navigation', () => {
it('shows notifications', () => {
cy.get('[data-testid=notificationDropdown]').should('not.be.visible');
cy.get('.notification-indicator').click();
cy.get('.bell-icon').click();
cy.get('[data-testid=notificationDropdown]').should('be.visible');
cy.get('[data-testid=notificationDropdown] .list-group-item').should('have.length', 3);
cy.get('[data-testid=notificationDropdown]')
@ -81,7 +78,7 @@ describe('Top Navigation', () => {
});
});
describe('locale switch', () => {
describe.skip('locale switch', () => {
beforeEach(() => {
cy.stubAuthenticate();
cy.visit('/');

View File

@ -1,74 +1,81 @@
[
{
"description": "TEXT_MESSAGE",
"source_user_id": "27bd4a30-d1b8-4eea-8454-01a104d59381",
"target_user_id": "a09f9a7e-afb7-489d-870d-e13a336e0b97",
"session_id": null,
"recording_id": null,
"invitation_id": null,
"join_request_id": null,
"friend_request_id": null,
"band_id": null,
"band_invitation_id": null,
"formatted_msg": "TEXT_MESSAGE",
"message": "Hello",
"created_at": "2021-10-07T00:09:57.704Z",
"lesson_session_id": null,
"purpose": null,
"source_user": {
"name": "Nuwan Chaturanga"
{
"next": null,
"unread_total": 3,
"notifications": [
{
"description": "TEXT_MESSAGE",
"source_user_id": "27bd4a30-d1b8-4eea-8454-01a104d59381",
"target_user_id": "a09f9a7e-afb7-489d-870d-e13a336e0b97",
"session_id": null,
"recording_id": null,
"invitation_id": null,
"join_request_id": null,
"friend_request_id": null,
"band_id": null,
"band_invitation_id": null,
"formatted_msg": "TEXT_MESSAGE",
"message": "Hello",
"created_at": "2021-10-07T00:09:57.704Z",
"lesson_session_id": null,
"purpose": null,
"source_user": {
"name": "Nuwan Chaturanga"
},
"notification_id": "63fcd878-9a22-4419-9cee-8a51a615da97",
"fan_access": null,
"musician_access": null,
"approval_required": null,
"read_at": null
},
"notification_id": "63fcd878-9a22-4419-9cee-8a51a615da97",
"fan_access": null,
"musician_access": null,
"approval_required": null
},
{
"description": "FRIEND_REQUEST",
"source_user_id": "a09f9a7e-afb7-489d-870d-e13a336e0b97",
"target_user_id": "b1ddadd0-0263-47c4-bf91-e7767f386970",
"session_id": null,
"recording_id": null,
"invitation_id": null,
"join_request_id": null,
"friend_request_id": "7c842904-24f5-4515-8886-0c3d25ee641b",
"band_id": null,
"band_invitation_id": null,
"formatted_msg": "Seth Call has sent you a friend request.",
"message": null,
"created_at": "2021-10-15T05:36:48.527Z",
"lesson_session_id": null,
"purpose": null,
"source_user": {
"name": "Seth Call"
{
"description": "FRIEND_REQUEST",
"source_user_id": "a09f9a7e-afb7-489d-870d-e13a336e0b97",
"target_user_id": "b1ddadd0-0263-47c4-bf91-e7767f386970",
"session_id": null,
"recording_id": null,
"invitation_id": null,
"join_request_id": null,
"friend_request_id": "7c842904-24f5-4515-8886-0c3d25ee641b",
"band_id": null,
"band_invitation_id": null,
"formatted_msg": "Seth Call has sent you a friend request.",
"message": null,
"created_at": "2021-10-15T05:36:48.527Z",
"lesson_session_id": null,
"purpose": null,
"source_user": {
"name": "Seth Call"
},
"notification_id": "3364b5f1-8946-46a3-b635-86d89d237849",
"fan_access": null,
"musician_access": null,
"approval_required": null,
"read_at": null
},
"notification_id": "3364b5f1-8946-46a3-b635-86d89d237849",
"fan_access": null,
"musician_access": null,
"approval_required": null
},
{
"description": "FRIEND_REQUEST_ACCEPTED",
"source_user_id": "29becbf4-8be5-4078-9405-0edadc9fa42d",
"target_user_id": "b1ddadd0-0263-47c4-bf91-e7767f386970",
"session_id": null,
"recording_id": null,
"invitation_id": null,
"join_request_id": null,
"friend_request_id": null,
"band_id": null,
"band_invitation_id": null,
"formatted_msg": "Peter Walker has accepted your friend request.",
"message": null,
"created_at": "2021-10-05T12:38:53.134Z",
"lesson_session_id": null,
"purpose": null,
"source_user": {
"name": "Peter Walker"
},
"notification_id": "bb9269f3-721c-48cd-9bf6-bcff72877198",
"fan_access": null,
"musician_access": null,
"approval_required": null
}
]
{
"description": "FRIEND_REQUEST_ACCEPTED",
"source_user_id": "29becbf4-8be5-4078-9405-0edadc9fa42d",
"target_user_id": "b1ddadd0-0263-47c4-bf91-e7767f386970",
"session_id": null,
"recording_id": null,
"invitation_id": null,
"join_request_id": null,
"friend_request_id": null,
"band_id": null,
"band_invitation_id": null,
"formatted_msg": "Peter Walker has accepted your friend request.",
"message": null,
"created_at": "2021-10-05T12:38:53.134Z",
"lesson_session_id": null,
"purpose": null,
"source_user": {
"name": "Peter Walker"
},
"notification_id": "bb9269f3-721c-48cd-9bf6-bcff72877198",
"fan_access": null,
"musician_access": null,
"approval_required": null,
"read_at": null
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -174,8 +174,8 @@
.num-circle {
position: absolute;
// top: "0px";
// left: "10px";
bottom: 13px;
left: 7px;
right: 0;
background-color: #cc0e0e;
color: #fff;

View File

@ -125,9 +125,11 @@ function JKDashboardMain() {
// };
const initJKScripts = () => {
if (scriptLoaded.current) {
return;
}
const app = window.JK.JamKazam();
@ -237,6 +239,7 @@ function JKDashboardMain() {
};
const handleNotification = (payload, type) => {
console.log('handleNotification', payload, type);
const notification = {
description: type,
notification_id: payload.notification_id,

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
@ -8,22 +8,31 @@ import ListGroupItem from 'reactstrap/es/ListGroupItem';
import { isIterableArray } from '../../helpers/utils';
import FalconCardHeader from '../common/FalconCardHeader';
import Notification from '../notification/JKNotification';
import { Scrollbar } from 'react-scrollbars-custom';
import { fetchNotifications } from '../../store/features/notificationSlice';
import { useDispatch, useSelector } from 'react-redux';
import { useAuth } from '../../context/UserAuth';
import { readNotifications } from '../../helpers/rest';
const JKNotificationDropdown = () => {
const { currentUser, isAuthenticated } = useAuth();
const dispatch = useDispatch();
const notifications = useSelector(state => state.notification.notifications);
const next = useSelector(state => state.notification.next);
const status = useSelector(state => state.notification.status);
const unread_total = useSelector(state => state.notification.unread_total);
const LIMIT = 5;
const [page, setPage] = useState(0);
const LIMIT = 20;
const MAX_COUNT_ON_BADGE = 99;
const [offset, setOffset] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [isAllRead, setIsAllRead] = useState(false);
const scrollbar = useRef();
// Handler
const handleToggle = e => {
e.preventDefault();
@ -34,26 +43,43 @@ const JKNotificationDropdown = () => {
try {
const options = {
userId: currentUser.id,
offset: 0,
limit: LIMIT + 1
offset: offset,
limit: LIMIT
};
await dispatch(fetchNotifications(options)).unwrap();
//console.log('NOTIFICATIONS', notifications);
//setPage(prev => prev + 1);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (isOpen) {
readNotifications(currentUser.id)
.then(() => {
setIsAllRead(true);
})
.catch(error => {
console.log(error);
});
}
}, [isOpen]);
useEffect(() => {
loadNotifications();
}, []);
useEffect(() => {
if (isOpen) {
if (offset > 0 && next !== null) {
loadNotifications();
}
}, [isOpen]);
}, [offset]);
const handleScrollStop = scrollValues => {
//update offset when scroll to bottom
if (scrollValues.scrollTop === scrollValues.scrollHeight - scrollValues.clientHeight) {
setOffset(prev => prev + LIMIT);
}
};
return (
<>
@ -64,6 +90,7 @@ const JKNotificationDropdown = () => {
className="mx-3"
isOpen={isOpen}
toggle={handleToggle}
// onMouseOver={() => {
// let windowWidth = window.innerWidth;
// windowWidth > 992 && setIsOpen(true);
@ -79,34 +106,41 @@ const JKNotificationDropdown = () => {
'': !isAllRead
})}
>
{ isIterableArray(notifications) && notifications.length > 0 && <div className="num-circle">
{ notifications.length > LIMIT ? `${LIMIT}+` : notifications.length}
</div> }
<FontAwesomeIcon icon={['fas', 'bell']} transform="shrink-5" className="fs-4" />
{isIterableArray(notifications) && notifications.length > 0 && !isAllRead && unread_total > 0 && (
<div className="num-circle" onClick={handleToggle}>{unread_total < MAX_COUNT_ON_BADGE ? unread_total : `${MAX_COUNT_ON_BADGE}+`}</div>
)}
<FontAwesomeIcon icon={['fas', 'bell']} transform="shrink-5" className="fs-4 bell-icon" onClick={handleToggle} />
</DropdownToggle>
<DropdownMenu right className="dropdown-menu-card" data-testid="notificationDropdown">
<Card className="card-notification shadow-none" style={{ maxWidth: '20rem' }}>
<FalconCardHeader className="card-header" title="Notifications" titleTag="h6" light={false}>
{/* <Link className="card-link font-weight-normal" to="#!">
Mark all as read
</Link> */}
<div className="card-link font-weight-normal" onClick={handleToggle}>
<Link className="card-link d-block" to="/notifications">
View all
</Link>
</div>
</FalconCardHeader>
<ListGroup flush className="font-weight-normal fs--1">
{isIterableArray(notifications) &&
notifications.slice(0, LIMIT).map(notification => (
<ListGroupItem
key={`notification-drop-item-${notification.notification_id}`}
onClick={handleToggle}
>
<Notification notification={notification} classNames="bg-200" flush />
</ListGroupItem>
))}
</ListGroup>
<div className="card-footer text-center border-top" onClick={handleToggle}>
<Link className="card-link d-block" to="/notifications">
View all
</Link>
</div>
<>
<ListGroup flush className="font-weight-normal fs--1">
<Scrollbar
ref={scrollbar}
onScrollStop={handleScrollStop}
style={{ width: '100%', height: 350 }}
mobileNative={true}
trackClickBehavior="step"
>
{isIterableArray(notifications) &&
notifications.map(notification => (
<ListGroupItem
key={`notification-drop-item-${notification.notification_id}`}
onClick={handleToggle}
>
<Notification notification={notification} classNames="bg-200" flush />
</ListGroupItem>
))}
</Scrollbar>
</ListGroup>
</>
</Card>
</DropdownMenu>
</Dropdown>

View File

@ -1,17 +1,19 @@
import React from 'react';
import React, { useEffect} from 'react';
import ProfileAvatar from '../profile/JKProfileAvatar'
import TimeAgo from '../common/JKTimeAgo';
import { useAuth } from '../../context/UserAuth';
import { useDispatch } from 'react-redux';
import { acceptFriendRequest } from '../../store/features/peopleSlice';
import { useDispatch, useSelector } from 'react-redux';
import { acceptFriendRequest, fetchPerson } from '../../store/features/peopleSlice';
import { Button } from 'reactstrap';
import useUserProfile from '../../hooks/useUserProfile';
function JKFriendRequestNotification(props) {
const { formatted_msg, created_at, friend_request_id } = props.notification;
const { formatted_msg, created_at, friend_request_id, source_user_id } = props.notification;
const handleOnAccept = props.handleOnAccept;
const { currentUser } = useAuth();
const dispatch = useDispatch();
const user = useSelector(state => state.people.people.find(person => person.id === source_user_id));
const { photoUrl } = useUserProfile(user); // user is the person who sent the message
const handleClick = async event => {
event.stopPropagation();
@ -32,10 +34,15 @@ function JKFriendRequestNotification(props) {
};
useEffect(() => {
if (!user)
dispatch(fetchPerson({ userId: source_user_id }))
}, []);
return (
<>
<div className="notification-avatar mr-3">
<ProfileAvatar />
<ProfileAvatar src={photoUrl} />
</div>
<div className="notification-body">
<p className="mb-1">{formatted_msg}</p>

View File

@ -1,15 +1,28 @@
import React from 'react'
import React, { useEffect } from 'react'
import ProfileAvatar from '../profile/JKProfileAvatar'
import TimeAgo from '../common/JKTimeAgo';
import useUserProfile from '../../hooks/useUserProfile';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPerson } from '../../store/features/peopleSlice';
const JKGenericNotification = (notification) => {
const {formatted_msg, created_at} = notification;
const {formatted_msg, created_at, source_user_id} = notification;
const dispatch = useDispatch();
const user = useSelector(state => state.people.people.find(person => person.id === source_user_id));
const { photoUrl } = useUserProfile(user); // user is the person who sent the message
useEffect(() => {
dispatch(fetchPerson({ userId: source_user_id }))
}, []);
return (
<>
<div className="notification-avatar mr-3">
<ProfileAvatar />
<ProfileAvatar src={photoUrl} />
</div>
<div className="notification-body">
<p className="mb-1">{formatted_msg}</p>

View File

@ -6,6 +6,7 @@ import JKMessageButton from '../profile/JKMessageButton';
import ProfileAvatar from '../profile/JKProfileAvatar';
import TimeAgo from '../common/JKTimeAgo';
import { truncate } from '../../helpers/utils';
import useUserProfile from '../../hooks/useUserProfile';
function JKTextMessageNotification(props) {
const { source_user, source_user_id, message, created_at } = props.notification;
@ -15,14 +16,17 @@ function JKTextMessageNotification(props) {
const user = useSelector(state => state.people.people.find(person => person.id === source_user_id));
const { photoUrl } = useUserProfile(user); // user is the person who sent the message
useEffect(() => {
dispatch(fetchPerson({ userId: source_user_id }))
if(!user)
dispatch(fetchPerson({ userId: source_user_id }))
}, []);
return (
<>
<div className="notification-avatar mr-3">
<ProfileAvatar />
<ProfileAvatar src={photoUrl} />
</div>
<div className="notification-body">
<p className="mb-1">

View File

@ -168,7 +168,17 @@ export const createLobbyChatMessage = options => {
export const getNotifications = (userId, options = {}) => {
return new Promise((resolve, reject) => {
apiFetch(`/users/${userId}/notifications?${new URLSearchParams(options)}`)
apiFetch(`/users/${userId}/my_notifications?${new URLSearchParams(options)}`)
.then(response => resolve(response))
.catch(error => reject(error));
});
};
export const readNotifications = (userId) => {
return new Promise((resolve, reject) => {
apiFetch(`/users/${userId}/my_notifications/read`, {
method: 'POST'
})
.then(response => resolve(response))
.catch(error => reject(error));
});

View File

@ -3,6 +3,8 @@ import {getNotifications, deleteNotification} from '../../helpers/rest'
const initialState = {
notifications: [],
next: null,
unread_total: 0,
status: 'idel',
error: null
}
@ -31,11 +33,16 @@ export const notificationsSlice = createSlice({
initialState,
reducers: {
add: (state, action) => {
console.log("notificationSlice - ADD", action.payload);
if(!action.payload.read_at){
state.unread_total += 1
}
state.notifications.unshift(action.payload)
},
modify: state => {},
remove: (state, action) => {
if(!action.payload.read_at){
state.unread_total -= 1
}
state.notifications = state.notifications.filter(n => n.id !== action.payload)
}
},
@ -45,10 +52,12 @@ export const notificationsSlice = createSlice({
state.status = 'loading'
})
.addCase(fetchNotifications.fulfilled, (state, action) => {
const records = new Set([...state.notifications, ...action.payload]);
const records = new Set([...state.notifications, ...action.payload.notifications]);
const unique = [];
records.map(x => unique.filter(a => a.notification_id === x.notification_id).length > 0 ? null : unique.push(x))
state.notifications = unique
state.next = action.payload.next
state.unread_total = action.payload.unread_total
state.status = 'succeeded'
})
.addCase(fetchNotifications.rejected, (state, action) => {
@ -57,7 +66,6 @@ export const notificationsSlice = createSlice({
})
.addCase(removeNotification.fulfilled, (state, action) => {
const notificationId = action.meta.arg.notificationId;
console.log('removeNotification.fulfilled', notificationId);
state.notifications = state.notifications.filter(n => n.notification_id !== notificationId)
})
}

View File

@ -0,0 +1,9 @@
class AddReadAtToNotifications < ActiveRecord::Migration
def self.up
execute("ALTER TABLE public.notifications ADD COLUMN read_at TIMESTAMP; UPDATE public.notifications SET read_at = created_at;")
end
def self.down
execute("ALTER TABLE public.notifications DROP COLUMN read_at;")
end
end

View File

@ -17,10 +17,13 @@ module JamRuby
belongs_to :jam_track_right, :class_name => "JamRuby::JamTrackRight", :foreign_key => "jam_track_right_id"
belongs_to :jam_track_mixdown_package, :class_name => "JamRuby::JamTrackMixdownPackage", :foreign_key => "jam_track_mixdown_package_id"
scope :unread, -> { where("read_at IS NULL") }
validates :target_user, :presence => true
validates :message, length: {minimum: 1, maximum: 400}, no_profanity: true, if: :text_message?
validate :different_source_target, if: :text_message?
validate :same_school_protection
def same_school_protection
if source_user && target_user
if !source_user.is_platform_instructor && !target_user.is_platform_instructor

View File

@ -37,6 +37,20 @@ describe Notification do
result
end
describe "unred notifications", focus: true do
it "returns unread notifications" do
notification = Notification.send_friend_request(@friend_request.id, @sender.id, @receiver.id)
unread = Notification.unread
unread.length.should == 1
unread[0].id.should == notification.id
notification.read_at = Time.now
notification.save!
unread = Notification.unread
unread.length.should == 0
end
end
describe "send friend request" do
it "sends email when user is offline and subscribes to emails" do
calls = count_publish_to_user_calls

View File

@ -10,7 +10,7 @@ class ApiUsersController < ApiController
:favorite_create, :favorite_destroy, # favorites
:friend_request_index, :friend_request_show, :friend_request_create, :friend_request_update, # friend requests
:friend_show, :friend_destroy, # friends
:notification_index, :notification_destroy, # notifications
:notification_index, :my_notifications, :read_notifications, :notification_destroy, # notifications
:band_invitation_index, :band_invitation_show, :band_invitation_update, # band invitations
:set_password, :begin_update_email, :update_avatar, :update_avatar_v2, :delete_avatar, :delete_avatar_v2, :generate_filepicker_policy, :request_reset_password,
:share_session, :share_recording,
@ -540,6 +540,24 @@ class ApiUsersController < ApiController
respond_with @notifications, responder: ApiResponder, :status => 200
end
# this is a special case for the new react front end app to get notifications
def my_notifications
limit = params[:limit].to_i
limit = 20 if limit <= 0
offset = params[:offset].to_i
offset = 0 if offset < 0
query = @notifications = @user.notifications.joins(:source_user)
@unread_total = query.unread.size
@notifications = query.offset(offset).limit(limit)
@next = @notifications.size > 0 ? offset + limit : nil
respond_with @notifications, responder: ApiResponder, :status => 200
end
def read_notifications
@user.notifications.unread.update_all(read_at: Time.now)
render :json => {}, :status => 204
end
def notification_destroy
Notification.delete(params[:notification_id])
respond_with responder: ApiResponder, :status => 204

View File

@ -0,0 +1,11 @@
node :next do |page|
@next
end
node :unread_total do |page|
@unread_total
end
node :notifications do |page|
partial "api_users/notification_index", object: @notifications
end

View File

@ -1,6 +1,6 @@
collection @notifications
attributes :description, :source_user_id, :target_user_id, :session_id, :recording_id, :invitation_id, :join_request_id, :friend_request_id, :band_id, :band_invitation_id, :formatted_msg, :message, :created_at, :lesson_session_id, :purpose
attributes :id, :description, :source_user_id, :target_user_id, :session_id, :recording_id, :invitation_id, :join_request_id, :friend_request_id, :band_id, :band_invitation_id, :formatted_msg, :message, :created_at, :lesson_session_id, :purpose
node :source_user do |n|
source_user_data = {}

View File

@ -493,6 +493,8 @@ Rails.application.routes.draw do
# notifications
match '/users/:id/notifications' => 'api_users#notification_index', :via => :get
match '/users/:id/my_notifications' => 'api_users#my_notifications', :via => :get
match '/users/:id/my_notifications/read' => 'api_users#read_notifications', :via => :post
match '/users/:id/notifications/:notification_id' => 'api_users#notification_destroy', :via => :delete
match '/users/:id/notifications' => 'api_users#notification_create', :via => :post