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 ( +
+

That's all for now

+
+ ) + } +} + +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 ? (
+ )} +
{
)} + + +
+ +
+
+ +
+
) } 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() })