diff --git a/packages/app/obojobo-express/__tests__/express_current_user.test.js b/packages/app/obojobo-express/__tests__/express_current_user.test.js
index e7d1fd9d62..30caf390f2 100644
--- a/packages/app/obojobo-express/__tests__/express_current_user.test.js
+++ b/packages/app/obojobo-express/__tests__/express_current_user.test.js
@@ -3,14 +3,28 @@ const userFunctions = ['setCurrentUser', 'getCurrentUser', 'requireCurrentUser']
jest.mock('test_node')
jest.mock('../server/models/user')
+jest.mock('../server/viewer/viewer_notification_state')
+
+const viewerNotificationState = require('../server/viewer/viewer_notification_state')
+
+jest.mock('../server/viewer/viewer_state', () => ({
+ get: jest.fn()
+}))
+
+const viewerState = require('../server/viewer/viewer_state')
describe('current user middleware', () => {
beforeAll(() => {})
afterAll(() => {})
beforeEach(() => {
+ jest.clearAllMocks()
mockArgs = (() => {
- const res = {}
- const req = { session: {} }
+ const res = {
+ cookie: jest.fn()
+ }
+ const req = {
+ session: {}
+ }
const mockJson = jest.fn().mockImplementation(() => {
return true
})
@@ -210,4 +224,76 @@ describe('current user middleware', () => {
})
return expect(req.saveSessionPromise()).rejects.toEqual('mock-error')
})
+ test('getNotifications sets notifications in cookies when notifications are available', async () => {
+ expect.assertions(6)
+
+ const { req, res } = mockArgs
+ const User = oboRequire('server/models/user')
+ const mockUser = new User({ id: 8, lastLogin: '2019-01-01' })
+ User.fetchById = jest.fn().mockResolvedValue(mockUser)
+ req.currentUserId = mockUser.id
+ req.currentUser = mockUser
+ req.currentUser.lastLogin = mockUser.lastLogin
+ jest.useFakeTimers('modern')
+ jest.setSystemTime(new Date(2024, 3, 1)) //mock the date so that runtime does not affect the date/time
+
+ const mockNotifications = [
+ { id: 1, title: 'Notification 1', text: 'Message 1' },
+ { id: 2, title: 'Notification 2', text: 'Message 2' }
+ ]
+ //simulate what would be added to the cookie
+ const mockNotificationsToCookie = [
+ { title: 'Notification 1', text: 'Message 1' },
+ { title: 'Notification 2', text: 'Message 2' }
+ ]
+
+ viewerState.get.mockResolvedValueOnce(req.currentUserId)
+ viewerNotificationState.getRecentNotifications.mockResolvedValueOnce(
+ mockNotifications.map(n => ({ id: n.id }))
+ )
+ viewerNotificationState.getNotifications.mockResolvedValueOnce(mockNotifications)
+
+ return req.getNotifications(req, res).then(() => {
+ const today = new Date()
+ expect(viewerState.get).toHaveBeenCalledWith(8)
+ expect(viewerNotificationState.getRecentNotifications).toHaveBeenCalled()
+ expect(viewerNotificationState.getNotifications).toHaveBeenCalledWith([1, 2])
+
+ expect(res.cookie).toHaveBeenCalledWith(
+ 'notifications',
+ JSON.stringify(mockNotificationsToCookie)
+ )
+ expect(req.currentUser.lastLogin).toStrictEqual(today)
+ expect(viewerNotificationState.setLastLogin).toHaveBeenCalledWith(8, today)
+
+ jest.useRealTimers()
+ })
+ })
+ test('getNotifications returns empty when there are no notifications', async () => {
+ expect.assertions(6)
+ const { req, res } = mockArgs
+ const User = oboRequire('server/models/user')
+ const mockUser = new User({ id: 8, lastLogin: '2019-01-01' })
+ User.fetchById = jest.fn().mockResolvedValue(mockUser)
+ req.currentUserId = mockUser.id
+ req.currentUser = mockUser
+ req.currentUser.lastLogin = mockUser.lastLogin
+ jest.useFakeTimers('modern')
+ jest.setSystemTime(new Date(2024, 3, 1)) //mock the date so that runtime does not affect the date/time
+
+ viewerState.get.mockResolvedValueOnce(req.currentUserId)
+ viewerNotificationState.getRecentNotifications.mockResolvedValueOnce(null)
+
+ return req.getNotifications(req, res).then(() => {
+ const today = new Date()
+ expect(viewerState.get).toHaveBeenCalledWith(8)
+ expect(viewerNotificationState.getRecentNotifications).toHaveBeenCalled() //With(req.currentUser.lastLogin)
+ expect(viewerNotificationState.getNotifications).not.toHaveBeenCalled()
+ expect(res.cookie).not.toHaveBeenCalled()
+ expect(req.currentUser.lastLogin).toStrictEqual(today)
+ expect(viewerNotificationState.setLastLogin).toHaveBeenCalledWith(8, today)
+
+ jest.useRealTimers()
+ })
+ })
})
diff --git a/packages/app/obojobo-express/__tests__/viewer_notification_state.test.js b/packages/app/obojobo-express/__tests__/viewer_notification_state.test.js
new file mode 100644
index 0000000000..fa9b70956f
--- /dev/null
+++ b/packages/app/obojobo-express/__tests__/viewer_notification_state.test.js
@@ -0,0 +1,65 @@
+const db = require('../server/db')
+const {
+ getNotifications,
+ getRecentNotifications,
+ setLastLogin
+} = require('../server/viewer/viewer_notification_state')
+
+jest.mock('../server/db')
+describe('db', () => {
+ beforeEach(() => {
+ jest.resetAllMocks()
+ jest.resetModules()
+ })
+
+ test('returns notifications when passed ids', () => {
+ const fakeNotifications = [
+ { title: 'Notification 1', text: 'This is notification 1' },
+ { title: 'Notification 2', text: 'This is notification 2' }
+ ]
+ db.manyOrNone.mockResolvedValue(fakeNotifications)
+
+ return getNotifications([1, 2]).then(result => {
+ expect(result).toEqual(fakeNotifications)
+ expect(db.manyOrNone).toHaveBeenCalledWith(expect.any(String), { ids: [1, 2] })
+ })
+ })
+
+ test('returns undefined when passed ids as 0', () => {
+ return expect(getNotifications(0)).toBeUndefined()
+ })
+
+ test('returns notifications created after a given date', () => {
+ const fakeNotifications = [{ id: 1 }, { id: 2 }]
+ db.manyOrNone.mockResolvedValue(fakeNotifications)
+
+ return getRecentNotifications('2022-01-01').then(result => {
+ expect(result).toEqual(fakeNotifications)
+ expect(db.manyOrNone).toHaveBeenCalledWith(expect.any(String), { date: '2022-01-01' })
+ })
+ })
+
+ test('should insert a new record if the user does not exist', () => {
+ db.none.mockResolvedValue()
+
+ const userId = 1
+ const today = '2023-09-13'
+
+ return setLastLogin(userId, today).then(() => {
+ expect(db.none).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO users'), {
+ userId,
+ today
+ })
+ })
+ })
+
+ test('should handle other errors from db.none', () => {
+ const errorMessage = 'Database error'
+ db.none.mockRejectedValue(new Error(errorMessage))
+
+ const userId = 1
+ const today = '2023-09-13'
+
+ return expect(setLastLogin(userId, today)).rejects.toThrow(errorMessage)
+ })
+})
diff --git a/packages/app/obojobo-express/server/express_current_user.js b/packages/app/obojobo-express/server/express_current_user.js
index 6e0f05d5c3..acabacddf3 100644
--- a/packages/app/obojobo-express/server/express_current_user.js
+++ b/packages/app/obojobo-express/server/express_current_user.js
@@ -1,6 +1,8 @@
const User = oboRequire('server/models/user')
const GuestUser = oboRequire('server/models/guest_user')
const logger = oboRequire('server/logger')
+const viewerNotificationState = oboRequire('server/viewer/viewer_notification_state')
+const viewerState = oboRequire('server/viewer/viewer_state')
const setCurrentUser = (req, user) => {
if (!(user instanceof User)) throw new Error('Invalid User for Current user')
@@ -56,11 +58,44 @@ const saveSessionPromise = req => {
})
}
+//retrieve notifications from the database and set them in the cookie
+const getNotifications = async (req, res) => {
+ return Promise.all([viewerState.get(req.currentUserId)])
+ .then(() => viewerNotificationState.getRecentNotifications(req.currentUser.lastLogin))
+ .then(result => {
+ if (result) {
+ return result.map(notifications => notifications.id)
+ }
+ return [0]
+ })
+ .then(ids => {
+ if (ids.some(id => id !== 0)) {
+ return viewerNotificationState.getNotifications(ids.filter(id => id !== 0))
+ }
+ })
+ .then(result => {
+ if (result) {
+ const parsedNotifications = result.map(notifications => ({
+ title: notifications.title,
+ text: notifications.text
+ }))
+ res.cookie('notifications', JSON.stringify(parsedNotifications))
+ }
+ return 0
+ })
+ .then(() => {
+ const today = new Date()
+ req.currentUser.lastLogin = today
+ viewerNotificationState.setLastLogin(req.currentUser.id, today)
+ })
+}
+
module.exports = (req, res, next) => {
req.setCurrentUser = setCurrentUser.bind(this, req)
req.getCurrentUser = getCurrentUser.bind(this, req)
req.requireCurrentUser = requireCurrentUser.bind(this, req)
req.resetCurrentUser = resetCurrentUser.bind(this, req)
req.saveSessionPromise = saveSessionPromise.bind(this, req)
+ req.getNotifications = getNotifications.bind(this, req, res)
next()
}
diff --git a/packages/app/obojobo-express/server/migrations/20240506192834-modify-users-add-last-login.js b/packages/app/obojobo-express/server/migrations/20240506192834-modify-users-add-last-login.js
new file mode 100644
index 0000000000..1c1adbca61
--- /dev/null
+++ b/packages/app/obojobo-express/server/migrations/20240506192834-modify-users-add-last-login.js
@@ -0,0 +1,31 @@
+'use strict'
+
+var dbm
+var type
+var seed
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate
+ type = dbm.dataType
+ seed = seedLink
+}
+
+exports.up = function(db) {
+ return db.addColumn('users', 'last_login', {
+ type: 'timestamp WITH TIME ZONE',
+ notNull: true,
+ defaultValue: new String('now()')
+ })
+}
+
+exports.down = function(db) {
+ return db.removeColumn('users', 'last_login')
+}
+
+exports._meta = {
+ version: 1
+}
diff --git a/packages/app/obojobo-express/server/migrations/20240506193547-create-notification-table.js b/packages/app/obojobo-express/server/migrations/20240506193547-create-notification-table.js
new file mode 100644
index 0000000000..7e3c52d261
--- /dev/null
+++ b/packages/app/obojobo-express/server/migrations/20240506193547-create-notification-table.js
@@ -0,0 +1,40 @@
+'use strict'
+
+var dbm
+var type
+var seed
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate
+ type = dbm.dataType
+ seed = seedLink
+}
+
+exports.up = function(db) {
+ return db.createTable('notifications', {
+ id: {
+ type: 'bigserial',
+ primaryKey: true,
+ notNull: true
+ },
+ created_at: {
+ type: 'timestamp WITH TIME ZONE',
+ notNull: true,
+ defaultValue: new String('now()')
+ },
+ text: { type: 'string', notNull: true },
+ title: { type: 'string', notNull: true }
+ })
+}
+
+exports.down = function(db) {
+ return db.dropTable('notifications')
+}
+
+exports._meta = {
+ version: 1
+}
diff --git a/packages/app/obojobo-express/server/models/user.js b/packages/app/obojobo-express/server/models/user.js
index e2c420667a..0565898619 100644
--- a/packages/app/obojobo-express/server/models/user.js
+++ b/packages/app/obojobo-express/server/models/user.js
@@ -13,6 +13,7 @@ class User {
email = null,
username = null,
createdAt = Date.now(),
+ lastLogin = Date.now(),
roles = [],
perms = null
} = {}) {
@@ -27,6 +28,7 @@ class User {
this.email = email
this.username = username
this.createdAt = createdAt
+ this.lastLogin = lastLogin
this.roles = roles
this.perms = [
...new Set(
@@ -47,6 +49,7 @@ class User {
email: result.email,
username: result.username,
createdAt: result.created_at,
+ lastLogin: result.last_login,
roles: result.roles,
perms: result.perms
})
diff --git a/packages/app/obojobo-express/server/viewer/viewer_notification_state.js b/packages/app/obojobo-express/server/viewer/viewer_notification_state.js
new file mode 100644
index 0000000000..32103a29a4
--- /dev/null
+++ b/packages/app/obojobo-express/server/viewer/viewer_notification_state.js
@@ -0,0 +1,52 @@
+const db = oboRequire('server/db')
+
+function getNotifications(ids) {
+ if (ids !== 0) {
+ return db.manyOrNone(
+ `
+ SELECT title,text
+ FROM notifications
+ WHERE id IN ($[ids:csv])
+ ORDER BY id ASC
+ `,
+ {
+ ids
+ }
+ )
+ }
+}
+
+function getRecentNotifications(date) {
+ return db.manyOrNone(
+ `
+ SELECT id
+ FROM notifications
+ WHERE created_at >= $[date]
+ ORDER BY created_at ASC
+ `,
+ {
+ date
+ }
+ )
+}
+
+function setLastLogin(userId, today) {
+ return db.none(
+ `
+ INSERT INTO users (id, last_login)
+ VALUES ($[userId], $[today])
+ ON CONFLICT (id) DO UPDATE
+ SET last_login = EXCLUDED.last_login
+ `,
+ {
+ userId,
+ today
+ }
+ )
+}
+
+module.exports = {
+ getNotifications,
+ getRecentNotifications,
+ setLastLogin
+}
diff --git a/packages/app/obojobo-repository/client/css/_defaults.scss b/packages/app/obojobo-repository/client/css/_defaults.scss
index a8326fc902..370797069b 100644
--- a/packages/app/obojobo-repository/client/css/_defaults.scss
+++ b/packages/app/obojobo-repository/client/css/_defaults.scss
@@ -32,6 +32,8 @@ $color-reward: #ffe65d;
$color-reward-text: #947d00;
$color-obojobo-blue: #0d4fa7;
$color-preview: #af1b5c;
+$color-notification: #af1b5c;
+$color-notification-focus: #fbdae6;
$size-spacing-vertical-big: 40px;
$size-spacing-vertical-half: $size-spacing-vertical-big / 2;
diff --git a/packages/app/obojobo-repository/server/routes/dashboard.js b/packages/app/obojobo-repository/server/routes/dashboard.js
index 260933d8b7..76794c22bc 100644
--- a/packages/app/obojobo-repository/server/routes/dashboard.js
+++ b/packages/app/obojobo-repository/server/routes/dashboard.js
@@ -43,7 +43,11 @@ const renderDashboard = (req, res, options) => {
let moduleCount = 0
let pageTitle = 'Dashboard'
- return getUserModuleCount(req.currentUser.id)
+ return req
+ .getNotifications(req, res)
+ .then(() => {
+ return getUserModuleCount(req.currentUser.id)
+ })
.then(count => {
moduleCount = count
return CollectionSummary.fetchByUserId(req.currentUser.id)
diff --git a/packages/app/obojobo-repository/server/routes/dashboard.test.js b/packages/app/obojobo-repository/server/routes/dashboard.test.js
index fc290f2f6c..aa865d5bf6 100644
--- a/packages/app/obojobo-repository/server/routes/dashboard.test.js
+++ b/packages/app/obojobo-repository/server/routes/dashboard.test.js
@@ -18,6 +18,7 @@ jest.setTimeout(10000) // extend test timeout?
// override requireCurrentUser for tests to provide our own user
let mockCurrentUser
+let mockNotifications
jest.mock('obojobo-express/server/express_current_user', () => (req, res, next) => {
req.requireCurrentUser = () => {
@@ -28,6 +29,10 @@ jest.mock('obojobo-express/server/express_current_user', () => (req, res, next)
req.currentUser = mockCurrentUser
return Promise.resolve(mockCurrentUser)
}
+ req.getNotifications = () => {
+ return Promise.resolve(mockNotifications)
+ }
+
next()
})
@@ -124,6 +129,7 @@ describe('repository dashboard route', () => {
id: 99,
hasPermission: perm => perm === 'canPreviewDrafts'
}
+ mockNotifications = []
CollectionSummary = require('../models/collection_summary')
DraftSummary = require('../models/draft_summary')
CountServices = require('../services/count')
diff --git a/packages/app/obojobo-repository/server/routes/library.test.js b/packages/app/obojobo-repository/server/routes/library.test.js
index 110a26b54f..cfbd43d2c9 100644
--- a/packages/app/obojobo-repository/server/routes/library.test.js
+++ b/packages/app/obojobo-repository/server/routes/library.test.js
@@ -13,6 +13,7 @@ jest.mock(
}),
{ virtual: true }
)
+jest.mock('react-modal')
jest.setTimeout(10000) // extend test timeout?
diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/dashboard.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/dashboard.test.js.snap
index e00b16e294..772bc3b7f9 100644
--- a/packages/app/obojobo-repository/shared/components/__snapshots__/dashboard.test.js.snap
+++ b/packages/app/obojobo-repository/shared/components/__snapshots__/dashboard.test.js.snap
@@ -79,6 +79,37 @@ exports[`Dashboard renders in MODE_RECENT with fewer modules than moduleCount, n
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+ That's all for now
+
+
+`;
+
+exports[`Notification component handles click on exit button and updates state and cookie 1`] = `
+
+
+
+
+ Title1
+
+
+
+
+ Notification1
+
+
+
+
+
+ Title2
+
+
+
+
+ Notification2
+
+
+
+`;
+
+exports[`Notification component handles click on exit button and updates state and cookie 2`] = `
+
+
+
+
+ Title2
+
+
+
+
+ Notification2
+
+
+
+`;
+
+exports[`Notification component hides the notification on exit button click 1`] = `
+
+
+
+
+ Test Title
+
+
+
+
+ Test Notification
+
+
+
+`;
+
+exports[`Notification component hides the notification on exit button click 2`] = `
+
+
+ That's all for now
+
+
+`;
+
+exports[`Notification component loads notifications from cookies on mount 1`] = `
+
+
+
+
+ Test Title
+
+
+
+
+ Test Notification
+
+
+
+`;
+
+exports[`Notification component renders nothing when document.cookie is null 1`] = `
+
+
+ That's all for now
+
+
+`;
+
+exports[`Notification component renders null when there are no notifications but document.cookie is not null 1`] = `
+
+
+ That's all for now
+
+
+`;
+
+exports[`Notification component renders without crashing 1`] = `
+
+
+ That's all for now
+
+
+`;
diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/repository-nav.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/repository-nav.test.js.snap
index 72da9e742d..f838fcc9e1 100644
--- a/packages/app/obojobo-repository/shared/components/__snapshots__/repository-nav.test.js.snap
+++ b/packages/app/obojobo-repository/shared/components/__snapshots__/repository-nav.test.js.snap
@@ -75,6 +75,130 @@ exports[`RepositoryNav does not render stats section with just canViewSystemStat
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`RepositoryNav loads notifications from cookies on mount 1`] = `
+
+
+
+
+
+
+
+
+
+
`;
@@ -153,6 +277,29 @@ exports[`RepositoryNav renders correctly with standard expected props 1`] = `
+
+
+
+
+
+
+
+
`;
@@ -194,6 +341,231 @@ exports[`RepositoryNav renders correctly with standard expected props but no log
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`RepositoryNav renders null when document.cookie is null 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`RepositoryNav renders null when there are no notifications but document.cookie is not null 1`] = `
+
+
+
+
+
+
+
+
+
+
`;
@@ -281,5 +653,28 @@ exports[`RepositoryNav renders stats section with canViewStatsPage 1`] = `
+
+
+
+
+
+
+
+
`;
diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/stats.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/stats.test.js.snap
index c276ed5fa3..2208c80cf3 100644
--- a/packages/app/obojobo-repository/shared/components/__snapshots__/stats.test.js.snap
+++ b/packages/app/obojobo-repository/shared/components/__snapshots__/stats.test.js.snap
@@ -79,6 +79,37 @@ exports[`Stats Renders "modules loaded" state correctly 1`] = `
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
{
}
const expectDialogToBeRendered = (component, dialogComponent, title) => {
- expect(ReactModal.setAppElement).toHaveBeenCalledTimes(1)
- expect(component.root.findByType(ReactModal).props.contentLabel).toBe(title)
+ expect(ReactModal.setAppElement).toHaveBeenCalledTimes(2)
+ const modal = component.root.findByProps({ contentLabel: title })
+ expect(modal).toBeDefined()
expect(component.root.findAllByType(dialogComponent).length).toBe(1)
+ const modalNotifications = component.root.findByProps({ contentLabel: 'Notifications' })
+ expect(modalNotifications).toBeDefined()
}
const expectMethodToBeCalledOnceWith = (method, calledWith = []) => {
@@ -487,8 +490,9 @@ describe('Dashboard', () => {
expect(moduleComponents[3].props.draftId).toBe('mockDraftId')
expect(moduleComponents[4].props.draftId).toBe('mockDraftId4')
- // Shouldn't be any modal dialogs open, either
- expect(component.root.findAllByType(ReactModal).length).toBe(0)
+ // Shouldn't be any modal dialogs open other than notifications, either
+ expect(component.root.findAllByType(ReactModal).length).toBe(1)
+ expect(component.root.findAllByType(ReactModal)[0].props.contentLabel).toBe('Notifications')
return component
}
@@ -2176,14 +2180,21 @@ describe('Dashboard', () => {
expectMethodToBeCalledOnceWith(dashboardProps.closeModal)
})
- test('renders no dialogs if props.dialog value is unsupported', () => {
+ test('renders only the notification modal if props.dialog value is unsupported', () => {
dashboardProps.dialog = 'some-unsupported-value'
let component
act(() => {
component = create(
)
})
- expect(component.root.findAllByType(ReactModal).length).toBe(0)
+ const modalInstances = component.root.findAllByType(ReactModal)
+
+ const notificationModals = modalInstances.filter(instance =>
+ instance.findByProps({ contentLabel: 'Notifications' })
+ )
+
+ expect(component.root.findAllByType(ReactModal).length).toBe(1)
+ expect(notificationModals.length).toBe(1)
component.unmount()
})
diff --git a/packages/app/obojobo-repository/shared/components/notification.jsx b/packages/app/obojobo-repository/shared/components/notification.jsx
new file mode 100644
index 0000000000..ca37f88310
--- /dev/null
+++ b/packages/app/obojobo-repository/shared/components/notification.jsx
@@ -0,0 +1,66 @@
+const React = require('react')
+require('./notification.scss')
+
+const Notification = ({ onDataFromNotification }) => {
+ const [notifications, setNotifications] = React.useState([])
+
+ React.useEffect(() => {
+ if (document && document.cookie) {
+ const cookiePropsRaw = decodeURIComponent(document.cookie).split(';')
+
+ let parsedValue
+ cookiePropsRaw.forEach(c => {
+ const parts = c.trim().split('=')
+ if (parts[0] === 'notifications') {
+ parsedValue = JSON.parse(parts[1])
+ }
+ })
+
+ const parsedNotifications = parsedValue
+ setNotifications(parsedNotifications)
+ }
+ }, [])
+
+ function onClickExitNotification(key) {
+ onDataFromNotification(notifications.length - 1)
+ setNotifications(prevNotifications => prevNotifications.filter((_, index) => index !== key))
+ }
+
+ React.useEffect(() => {
+ const jsonNotifications = JSON.stringify(notifications)
+ const cookieString = `${encodeURIComponent(jsonNotifications)};`
+ document.cookie = 'notifications=' + cookieString
+ }, [notifications])
+
+ const renderNotification = (key, text, title) => {
+ return (
+
+
+
{title}
+
+
+
{text}
+
+ )
+ }
+
+ if (notifications && notifications.length >= 1) {
+ return (
+
+ {notifications.map((notification, key) =>
+ renderNotification(key, notification.text, notification.title)
+ )}
+
+ )
+ } else {
+ return (
+
+ )
+ }
+}
+
+module.exports = Notification
diff --git a/packages/app/obojobo-repository/shared/components/notification.scss b/packages/app/obojobo-repository/shared/components/notification.scss
new file mode 100644
index 0000000000..fc05c98dbd
--- /dev/null
+++ b/packages/app/obojobo-repository/shared/components/notification.scss
@@ -0,0 +1,77 @@
+@import '~styles/includes';
+@import '../../client/css/defaults';
+
+.notification-banner {
+ background-color: $color-bg;
+ padding: 1em;
+ color: $color-text;
+ border-radius: 0.5em;
+ margin: 0.1em;
+ text-align: left;
+ border: solid;
+ border-color: $color-notification;
+ border-width: 0.3em;
+
+ h1 {
+ margin: 0;
+ font-size: 0.6cm;
+ }
+
+ p {
+ display: block;
+ font-size: 0.5cm;
+ }
+
+ .notification-header {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+
+ .notification-exit-button {
+ color: $color-notification;
+ background-color: $color-bg;
+ border: none;
+ border-radius: 6em;
+ font-size: 0.8cm;
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+ padding-top: 0.1em;
+ padding-bottom: 0.3em;
+ cursor: pointer;
+ }
+
+ .notification-exit-button:hover {
+ color: $color-notification-focus;
+ background-color: $color-notification;
+ }
+ }
+}
+
+.notification-banner.hidden {
+ display: none;
+}
+
+.notification-banner:hover {
+ background-color: $color-notification-focus;
+ color: $color-text;
+ transition: all 0.2s ease;
+ -webkit-transition: all 0.2s ease;
+}
+
+.notification-none {
+ color: $color-notification;
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ p {
+ border-style: solid;
+ border-radius: 0.5em;
+ padding: 0.5em;
+ width: 50%;
+ border-color: $color-notification;
+ font-size: 3em;
+ }
+}
diff --git a/packages/app/obojobo-repository/shared/components/notification.test.js b/packages/app/obojobo-repository/shared/components/notification.test.js
new file mode 100644
index 0000000000..2ccbd59688
--- /dev/null
+++ b/packages/app/obojobo-repository/shared/components/notification.test.js
@@ -0,0 +1,144 @@
+import React from 'react'
+import { create, act } from 'react-test-renderer'
+import Notification from './notification'
+
+describe('Notification component', () => {
+ beforeAll(() => {
+ Object.defineProperty(document, 'cookie', {
+ value: '',
+ writable: true
+ })
+ })
+
+ test('renders without crashing', () => {
+ const component = create(
{}} />) // Provide a mock function for onDataFromNotification
+ const tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+ })
+ test('renders nothing when document.cookie is null', () => {
+ const onDataFromNotification = jest.fn()
+ const originalDocument = document.cookie
+ Object.defineProperty(document, 'cookie', { value: null, writable: true })
+
+ const reusableComponent =
+ let component
+ act(() => {
+ component = create(reusableComponent)
+ })
+ const tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+
+ document.cookie = originalDocument
+ })
+ test('loads notifications from cookies on mount', () => {
+ const onDataFromNotification = jest.fn()
+ const notificationValue = [{ key: 1, text: 'Test Notification', title: 'Test Title' }]
+ document.cookie = `notifications=${JSON.stringify(notificationValue)}`
+
+ const reusableComponent =
+ let component
+ act(() => {
+ component = create(reusableComponent)
+ })
+
+ const tree = component.toJSON()
+
+ expect(tree).toMatchSnapshot()
+ expect(document.cookie).toBe(
+ `notifications=${encodeURIComponent(JSON.stringify(notificationValue))};`
+ )
+ })
+
+ test('hides the notification on exit button click', () => {
+ const onDataFromNotification = jest.fn()
+ document.cookie =
+ 'notifications=' +
+ JSON.stringify([{ key: 1, text: 'Test Notification', title: 'Test Title' }])
+
+ const reusableComponent =
+ let component
+ act(() => {
+ component = create(reusableComponent)
+ })
+
+ let tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+
+ const exitButtons = component.root.findAllByProps({ className: 'notification-exit-button' })
+
+ act(() => {
+ exitButtons[0].props.onClick()
+ })
+
+ tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+ })
+ test('handles click on exit button and updates state and cookie', () => {
+ const onDataFromNotification = jest.fn()
+ const notificationValue = [
+ { key: 1, text: 'Notification1', title: 'Title1' },
+ { key: 2, text: 'Notification2', title: 'Title2' }
+ ]
+ document.cookie = `notifications=${JSON.stringify(notificationValue)}`
+
+ const elementToExit = document.getElementsByClassName('notification-exit-button')[0]
+ const reusableComponent =
+ let component
+ act(() => {
+ component = create(reusableComponent)
+ })
+
+ let tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+
+ const key = 0
+ const exitButtons = component.root.findAllByProps({ className: 'notification-exit-button' })
+
+ act(() => {
+ exitButtons[key].props.onClick()
+ })
+
+ const newNotificationValue = [{ key: 2, text: 'Notification2', title: 'Title2' }]
+ tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+ expect(document.cookie).toBe(
+ `notifications=${encodeURIComponent(JSON.stringify(newNotificationValue))};`
+ )
+ expect(elementToExit).toBe(undefined)
+ })
+
+ test('renders null when there are no notifications but document.cookie is not null', () => {
+ const onDataFromNotification = jest.fn()
+ const reusableComponent =
+ const originalDocument = document.cookie
+ let component
+ const cookieValue = 'otherrandomdata=otherrandomdata'
+ document.cookie = cookieValue
+ act(() => {
+ component = create(reusableComponent)
+ })
+ const tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+
+ expect(document).not.toBeNull()
+ expect(document.cookie).not.toBeNull()
+
+ const cookiePropsRaw = decodeURIComponent(document.cookie).split(';')
+
+ const parts = cookiePropsRaw[0].trim().split('=')
+
+ expect(parts[1]).toBe('undefined')
+ document.cookie = originalDocument
+ })
+ test('does not update cookie when there are no notifications', () => {
+ const onDataFromNotification = jest.fn()
+ const notificationValue = []
+ document.cookie = `notifications=${JSON.stringify(notificationValue)}`
+
+ const component = create()
+ const tree = component.toJSON()
+
+ expect(tree).toMatchSnapshot()
+ expect(document.cookie).toBe(`notifications=${JSON.stringify(notificationValue)}`)
+ })
+})
diff --git a/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-error.test.js.snap b/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-error.test.js.snap
index 52d1b40ac6..8f0c4a949c 100644
--- a/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-error.test.js.snap
+++ b/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-error.test.js.snap
@@ -112,6 +112,37 @@ exports[`PageError renders when given props 1`] = `
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
+
+
+
+
+
+
+
+ That's all for now
+
+
+
+
props => {
+ return
+})
import React from 'react'
import renderer from 'react-test-renderer'
+import ReactModal from 'react-modal'
mockStaticDate()
@@ -7,6 +11,7 @@ mockStaticDate()
const PageError = require('./page-error')
describe('PageError', () => {
+ ReactModal.setAppElement = jest.fn()
test('renders when given props', () => {
const mockCurrentUser = {
id: 99,
diff --git a/packages/app/obojobo-repository/shared/components/pages/page-homepage.test.js b/packages/app/obojobo-repository/shared/components/pages/page-homepage.test.js
index a7deb0aa9d..fe695b106f 100644
--- a/packages/app/obojobo-repository/shared/components/pages/page-homepage.test.js
+++ b/packages/app/obojobo-repository/shared/components/pages/page-homepage.test.js
@@ -1,5 +1,9 @@
+jest.mock('react-modal', () => props => {
+ return
+})
import React from 'react'
import renderer from 'react-test-renderer'
+import ReactModal from 'react-modal'
mockStaticDate()
@@ -7,6 +11,7 @@ mockStaticDate()
const PageHomepage = require('./page-homepage')
describe('PageHomepage', () => {
+ ReactModal.setAppElement = jest.fn()
test('renders when given props', () => {
const mockCurrentUser = {
id: 99,
diff --git a/packages/app/obojobo-repository/shared/components/pages/page-login.test.js b/packages/app/obojobo-repository/shared/components/pages/page-login.test.js
index 91a17f96f7..2c6578c941 100644
--- a/packages/app/obojobo-repository/shared/components/pages/page-login.test.js
+++ b/packages/app/obojobo-repository/shared/components/pages/page-login.test.js
@@ -1,5 +1,9 @@
+jest.mock('react-modal', () => props => {
+ return
+})
import React from 'react'
import renderer from 'react-test-renderer'
+import ReactModal from 'react-modal'
mockStaticDate()
@@ -7,6 +11,7 @@ mockStaticDate()
const PageLogin = require('./page-login')
describe('PageLogin', () => {
+ ReactModal.setAppElement = jest.fn()
test('renders when given props', () => {
const mockCurrentUser = {
id: 99,
diff --git a/packages/app/obojobo-repository/shared/components/pages/page-module.test.js b/packages/app/obojobo-repository/shared/components/pages/page-module.test.js
index 57c61106f5..dca534e4e5 100644
--- a/packages/app/obojobo-repository/shared/components/pages/page-module.test.js
+++ b/packages/app/obojobo-repository/shared/components/pages/page-module.test.js
@@ -1,9 +1,13 @@
jest.mock('../../api-util')
jest.mock('dayjs')
+jest.mock('react-modal', () => props => {
+ return
+})
import React from 'react'
import PageModule from './page-module'
import { create, act } from 'react-test-renderer'
+import ReactModal from 'react-modal'
const Button = require('../button')
const ButtonLink = require('../button-link')
@@ -23,6 +27,7 @@ describe('PageModule', () => {
fromNow: () => 'A long time ago'
}))
dayjs.extend = jest.fn()
+ ReactModal.setAppElement = jest.fn()
})
beforeEach(() => {
diff --git a/packages/app/obojobo-repository/shared/components/repository-nav.jsx b/packages/app/obojobo-repository/shared/components/repository-nav.jsx
index 361dfd0fd1..acb5cb2555 100644
--- a/packages/app/obojobo-repository/shared/components/repository-nav.jsx
+++ b/packages/app/obojobo-repository/shared/components/repository-nav.jsx
@@ -1,12 +1,22 @@
require('./repository-nav.scss')
const React = require('react')
-const { useState } = require('react')
const Avatar = require('./avatar')
+const Notification = require('./notification')
+const ReactModal = require('react-modal')
const RepositoryNav = props => {
let timeOutId
- const [isMenuOpen, setMenuOpen] = useState(false)
+ const [isMenuOpen, setMenuOpen] = React.useState(false)
+ const [isNotificationsOpen, setNotificationsOpen] = React.useState(false)
+ const [numberNotifications, setNumberNotifications] = React.useState(0)
+ ReactModal.setAppElement('#react-hydrate-root')
+
+ const handleNotificationsData = numberOfNotificationsData => {
+ // Handle the data received from the Notification component
+ setNumberNotifications(numberOfNotificationsData)
+ }
+
const onCloseMenu = () => setMenuOpen(false)
const onToggleMenu = e => {
setMenuOpen(!isMenuOpen)
@@ -21,6 +31,32 @@ const RepositoryNav = props => {
const onFocusHandler = () => {
clearTimeout(timeOutId)
}
+ const onToggleNotifications = e => {
+ setNotificationsOpen(!isNotificationsOpen)
+ e.preventDefault() // block the event from bubbling out to the parent href
+ }
+ function onClickExitPopup() {
+ setNotificationsOpen(false)
+ }
+
+ React.useEffect(() => {
+ //to set the number of notifications initially
+ if (document && document.cookie) {
+ const cookiePropsRaw = decodeURIComponent(document.cookie).split(';')
+
+ let parsedValue
+ cookiePropsRaw.forEach(c => {
+ const parts = c.trim().split('=')
+ if (parts[0] === 'notifications') {
+ parsedValue = JSON.parse(parts[1])
+ }
+ })
+
+ if (parsedValue && parsedValue.length >= 1) {
+ setNumberNotifications(parsedValue.length)
+ }
+ }
+ }, [])
return (
{
{props.userId !== 0 ? (
)}
+
+
+
+
+ X
+
+
+
+
+
+
)
}
diff --git a/packages/app/obojobo-repository/shared/components/repository-nav.scss b/packages/app/obojobo-repository/shared/components/repository-nav.scss
index 92c67e7603..5c17efd0e8 100644
--- a/packages/app/obojobo-repository/shared/components/repository-nav.scss
+++ b/packages/app/obojobo-repository/shared/components/repository-nav.scss
@@ -8,7 +8,7 @@
.repository--stick-to-top {
position: sticky;
top: 0;
- background: #ffffff;
+ background: $color-bg;
min-width: 100%;
z-index: 100;
border-bottom: 1px solid $border-color;
@@ -76,7 +76,7 @@
position: absolute;
left: -1em;
top: 3.7em;
- background: #ffffff;
+ background: $color-bg;
padding: 20px;
border-radius: 3px;
width: 100%;
@@ -119,6 +119,79 @@
width: 2.9em;
margin: 0;
}
+
+ .notification-indicator {
+ color: $color-notification;
+ cursor: pointer;
+ height: 1.5em;
+ }
+
+ .repository--nav--current-user--name {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ }
+}
+
+.popup {
+ display: flex;
+ position: fixed;
+ flex-direction: column;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 50%;
+ height: 50%;
+ max-width: 600px;
+ border: 1px solid $color-shadow;
+ border-radius: 10px;
+ background: $color-bg;
+ padding: 20px;
+ z-index: 1000;
+ box-shadow: 0 0 20px 0 $color-shadow;
+ overflow: hidden;
+
+ .exit-container {
+ position: fixed;
+ top: 3px;
+ right: 3px;
+
+ .exit-button {
+ border: none;
+ background: none;
+ border-radius: 6em;
+ font-size: 0.5cm;
+ cursor: pointer;
+ z-index: 1000;
+ }
+
+ .exit-button:hover {
+ color: $color-notification;
+ }
+ }
+
+ .notification-container {
+ justify-content: center;
+ align-items: center;
+ overflow-y: auto;
+ max-height: 100%;
+ }
+}
+
+.popup.active {
+ text-align: center;
+ display: flex;
+ justify-content: center;
+}
+
+.overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: $color-shadow;
+ z-index: 999;
}
// if the last child is a link (login link instead of logged in user avatar)
diff --git a/packages/app/obojobo-repository/shared/components/repository-nav.test.js b/packages/app/obojobo-repository/shared/components/repository-nav.test.js
index fe5b672055..f6f60d3a62 100644
--- a/packages/app/obojobo-repository/shared/components/repository-nav.test.js
+++ b/packages/app/obojobo-repository/shared/components/repository-nav.test.js
@@ -1,10 +1,22 @@
+jest.mock('./notification', () => props => {
+ return
{props.children}
+})
+jest.mock('react-modal', () => props => {
+ return
+})
+
import React from 'react'
import RepositoryNav from './repository-nav'
import { create, act } from 'react-test-renderer'
+import Notification from './notification'
+import ReactModal from 'react-modal'
describe('RepositoryNav', () => {
let navProps
+ beforeAll(() => {
+ ReactModal.setAppElement = jest.fn()
+ })
beforeEach(() => {
jest.resetAllMocks()
jest.useFakeTimers()
@@ -14,8 +26,14 @@ describe('RepositoryNav', () => {
displayName: 'Display Name',
userPerms: []
}
+ Object.defineProperty(document, 'cookie', {
+ value: '',
+ writable: true
+ })
+ })
+ afterEach(() => {
+ jest.resetAllMocks()
})
-
const expectMenuToBeOpen = component => {
expect(
component.root.findAllByProps({ className: 'repository--nav--current-user--menu is-open' })
@@ -29,6 +47,22 @@ describe('RepositoryNav', () => {
}).length
).toBe(1)
}
+ const expectNotificationsPopupToBeOpen = component => {
+ const modalInstances = component.root.findAllByType(ReactModal)
+
+ const modalInstance = modalInstances.find(instance =>
+ instance.findByProps({ contentLabel: 'Notifications' })
+ )
+ expect(modalInstance.props.isOpen).toBe(true)
+ }
+ const expectNotificationsPopupToBeClosed = component => {
+ const modalInstances = component.root.findAllByType(ReactModal)
+
+ const modalInstance = modalInstances.find(instance =>
+ instance.findByProps({ contentLabel: 'Notifications' })
+ )
+ expect(modalInstance.props.isOpen).toBe(false)
+ }
// default props.userId = 0 means there is no user logged in
test('renders correctly with standard expected props but no logged in user', () => {
@@ -169,4 +203,123 @@ describe('RepositoryNav', () => {
})
expectMenuToBeClosed(component)
})
+
+ test('loads notifications from cookies on mount', () => {
+ const notificationValue = { key: 1, text: 'TestNotification', title: 'TestTitle' }
+ document.cookie = `notifications=${JSON.stringify(notificationValue)}`
+ const component = create(
)
+ const tree = component.toJSON()
+
+ expect(tree).toMatchSnapshot()
+ expect(document.cookie).toBe(`notifications=${JSON.stringify(notificationValue)}`)
+ })
+
+ test('renders null when document.cookie is null', () => {
+ const originalDocument = document.cookie
+ document.cookie = null
+
+ const reusableComponent =
+ let component
+ act(() => {
+ component = create(reusableComponent)
+ })
+ const tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+
+ expect(document).not.toBeNull()
+ expect(document.cookie).toBe(null)
+
+ document.cookie = originalDocument
+ })
+
+ test('toggles notifications popup on button click', () => {
+ document.cookie =
+ 'notifications=' +
+ JSON.stringify([
+ { key: 1, text: 'Notification1', title: 'Title1' },
+ { key: 2, text: 'Notification2', title: 'Title2' }
+ ])
+
+ const component = create(
)
+ act(() => {
+ component.update(
)
+ })
+
+ const mockClickEvent = { preventDefault: jest.fn() }
+
+ act(() => {
+ component.root
+ .findByProps({ className: 'repository--nav--current-user--name' })
+ .children[1].props.onClick(mockClickEvent)
+ component.update(
)
+ })
+ expectNotificationsPopupToBeOpen(component)
+
+ act(() => {
+ component.root.findByProps({ className: 'exit-button' }).props.onClick()
+ component.update(
)
+ })
+ expectNotificationsPopupToBeClosed(component)
+ })
+
+ test('handles notification data from Notification component', () => {
+ document.cookie =
+ 'notifications=' +
+ JSON.stringify([
+ { key: 1, text: 'Notification1', title: 'Title1' },
+ { key: 2, text: 'Notification2', title: 'Title2' }
+ ])
+
+ const component = create(
)
+ act(() => {
+ component.update(
)
+ })
+
+ const mockClickEvent = { preventDefault: jest.fn() }
+
+ act(() => {
+ component.root
+ .findByProps({ className: 'repository--nav--current-user--name' })
+ .children[1].props.onClick(mockClickEvent)
+ component.update(
)
+ })
+
+ const notificationComponentInstance = component.root.findByType(Notification)
+
+ // Manually call the onDataFromNotification prop with some test data, this normally happens notificationIsOpen is true and Notifications are rendered
+ act(() => {
+ notificationComponentInstance.props.onDataFromNotification(5)
+ })
+
+ expect(
+ component.root.findByProps({ className: 'repository--nav--current-user--name' }).children[1]
+ .props.children[0]
+ ).toBe(5)
+ })
+ test('renders null when there are no notifications but document.cookie is not null', () => {
+ const reusableComponent =
+ let component
+ let parsedValue
+ const cookieValue = 'otherrandomdata=otherrandomdata'
+ document.cookie = cookieValue
+ act(() => {
+ component = create(reusableComponent)
+ })
+ const tree = component.toJSON()
+ expect(tree).toMatchSnapshot()
+
+ expect(document).not.toBeNull()
+ expect(document.cookie).not.toBeNull()
+
+ const cookiePropsRaw = decodeURIComponent(document.cookie).split(';')
+
+ cookiePropsRaw.forEach(c => {
+ const parts = c.trim().split('=')
+
+ expect(parts[0]).not.toBe('notifications')
+ expect(parts[0] === 'notifications').toBe(false)
+ })
+ expect(document.cookie).toBe(cookieValue)
+ expect(parsedValue).toBe(undefined)
+ })
})
diff --git a/packages/app/obojobo-repository/shared/components/stats.test.js b/packages/app/obojobo-repository/shared/components/stats.test.js
index 633a4a2eaa..2cdf61f73a 100644
--- a/packages/app/obojobo-repository/shared/components/stats.test.js
+++ b/packages/app/obojobo-repository/shared/components/stats.test.js
@@ -1,3 +1,6 @@
+jest.mock('react-modal', () => props => {
+ return
+})
import React from 'react'
import { create, act } from 'react-test-renderer'
import Button from './button'
@@ -5,6 +8,7 @@ import Button from './button'
import Stats from './stats'
import AssessmentStats from './stats/assessment-stats'
import DataGridDrafts from './stats/data-grid-drafts'
+import ReactModal from 'react-modal'
jest.mock('react-data-table-component', () => ({
default: props => (
@@ -35,7 +39,9 @@ describe('Stats', () => {
revisionCount: 1
}
]
-
+ beforeAll(() => {
+ ReactModal.setAppElement = jest.fn()
+ })
beforeEach(() => {
jest.resetAllMocks()
})