Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions UserGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,85 @@ This plugin and test suite were developed as part of Sprint 2, focusing on autom
This feature was developed with assistance from ChatGPT (OpenAI), since it was very complicated.
ChatGPT was used throughout the implementation, debugging, and testing of the Thread Summarizer plugin.
All code and explanations were implimented, reviewed, modified, and verified by me (Salwa Al-Kuwari).

---


# Notifications Pop Up Plugin – User Guide
Course: 17-313 Foundations of Software Engineering (Fall 2025)
Author: Moza Al Fahad

---

## 1. Overview

The Notifications Pop Up plugin displays the unread notifications for the user when they first sign in. It gets automatically updated when new notifications arrive. From the pop up which first appears after login, the users have the choice to open the notification or mark all notifications as read. When the user chooses to read the notification, the page redirects them to the notification.

---

## 2. Features Implemented

- A pop up modal automatically appears when a user logs in and has unread notifications.
- Uses NodeBB’s built-in WebSockets to update the modal instantly when a new notification arrives.
- Users can mark individual notifications or mark all as read directly from the pop-up.
- Simple UI designed to fit within NodeBB’s theme layout for clarity and accessibility.
- Implemented with a single JS file (unread-pop-lite.js) registered as a NodeBB client script.

---

## 3. How It Works

1. When a user logs in, the plugin checks for any unread notifications.
2. If unread notifications exist, a pop-up modal is dynamically created and displayed.
3. Each notification includes a short preview and a link to the related post/topic.
4. Users can:
- Click on a notification to open it, which automatically marks it as read.
- Click “Mark All as Read” to clear all unread notifications.
5. Real-time updates are handled through NodeBB socket events, ensuring new notifications appear immediately without page refreshes.
6. If no unread notifications exist, the modal does not appear.

---

## 5. Automated Testing & Running Tests

Main test file:
- Located in test/unread-pop-lite.spec.js

To lint and run tests:
npm run lint
npm run test:popup
All tests should pass.

---

## 6. Front-end Testing

1. Log into NodeBB with a test account.
2. Trigger a notification (e.g., mention the test account from another user).
3. Verify that:
- On login, a popup appears listing unread notifications.
- Clicking a notification opens the linked post and removes it from the list.
- Clicking “Mark all read” clears the list

---

## 10. Author and Acknowledgement

Author: Moza Al Fahad
Course: 17-313 Foundations of Software Engineering (Fall 2025)
Institution: Carnegie Mellon University in Qatar

This plugin was developed at Sprint 1 and automated test suite was developed in Sprint 2, focusing on automated testing and user documentation for software features.

---

**AI Assistance Disclosure**

This feature was developed with assistance from ChatGPT (OpenAI). It was used for debugging, generating automated tests, and implementing the pop up feature.
All code and explanations were implimented, reviewed, modified, and verified by me (Moza Al Fahad).






12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"lint": "eslint --cache ./nodebb .",
"test": "NYC_TEMP_DIR=.nyc_tmp NYC_OUTPUT_DIR=.nyc_cov nyc --temp-dir .nyc_tmp --report-dir .nyc_cov --reporter=html --reporter=text-summary mocha \"test/**/*.test.js\" --exit",
"coverage": "nyc report --reporter=text-lcov > ./coverage/lcov.info",
"coveralls": "nyc report --reporter=text-lcov | coveralls && rm -r coverage"
"coveralls": "nyc report --reporter=text-lcov | coveralls && rm -r coverage",
"test:popup": "jest test/unread-pop-lite.spec.js"
},
"nyc": {
"exclude": [
Expand Down Expand Up @@ -170,6 +171,8 @@
"grunt": "1.6.1",
"grunt-contrib-watch": "1.1.0",
"husky": "8.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "26.1.0",
"lint-staged": "16.0.0",
"mocha": "^10.8.2",
Expand Down Expand Up @@ -201,5 +204,8 @@
"email": "baris@nodebb.org",
"url": "https://github.com/barisusakli"
}
]
}
],
"jest": {
"testEnvironment": "jsdom"
}
}
223 changes: 223 additions & 0 deletions test/unread-pop-lite.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/* eslint-env node */
/* global jest, test, expect */

/**
* test/unread-pop-lite.spec.js
* Keep plugin unchanged; adapt tests to be robust to different impls.
*/

const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

const fs = require('fs');
const path = require('path');
const { JSDOM } = require('jsdom');

// --- Enhanced Emitter to track registrations & invocations (ES5-friendly) ---
class Emitter {
constructor() {
this.map = {};
this.calls = {};
}
on(evt, fn) {
(this.map[evt] || (this.map[evt] = [])).push(fn);
}
emit(evt, payload, cb) {
const fns = this.map[evt] || [];
this.calls[evt] = (this.calls[evt] || 0) + 1;
for (let i = 0; i < fns.length; i++) fns[i](payload);
if (typeof cb === 'function') cb();
}
hasHandlerFor(anyOf) {
for (let i = 0; i < anyOf.length; i++) {
const evt = anyOf[i];
if (Array.isArray(this.map[evt]) && this.map[evt].length > 0) return true;
}
return false;
}
}

const tick = () => new Promise(r => setTimeout(r, 0));

describe('Unread Popup (Lite)', () => {
let $, window, document, socket, ajaxify, app;

const pluginPath = path.join(
process.cwd(),
'plugins',
'nodebb-plugin-unread-pop-lite',
'public',
'js',
'unread-pop-lite.js'
);

function injectFixtureDom(doc) {
const container = doc.createElement('div');
container.innerHTML = `
<div id="unread-pop-lite" aria-hidden="false">
<div class="upl-header">
<button id="upl-mark-all" type="button">Mark all read</button>
</div>
<ul id="upl-list" aria-live="polite"></ul>
<div id="upl-empty" style="display:none">You're all caught up ✨</div>
</div>
`;
doc.body.appendChild(container.firstElementChild);
}

beforeEach(() => {
const dom = new JSDOM(`<!doctype html><html><body></body></html>`, {
url: 'http://localhost:4567/',
pretendToBeVisual: true,
});
window = dom.window;
document = dom.window.document;

const jq = require('jquery');
if (typeof jq === 'function' && !jq.fn) {
$ = jq(window);
} else {
$ = jq;
}

global.window = window;
global.document = document;
global.$ = $;
global.jQuery = $;
window.$ = $;
window.jQuery = $;

if (!$.fn) $.fn = {};
$.fn.modal = function () { return this; };

app = { user: { uid: 1 } };
ajaxify = { go: jest.fn() };
socket = new Emitter();

global.app = app;
global.ajaxify = ajaxify;
global.socket = socket;

const unread = [
{ nid: '101', path: '/topic/1/first', bodyShort: 'First', read: false, datetimeISO: '2025-10-01T12:00:00Z' },
{ nid: '102', path: '/topic/1/second', bodyShort: 'Second', read: false, datetimeISO: '2025-10-02T12:00:00Z' },
];
jest.spyOn($, 'get').mockImplementation((url, cb) => {
if (typeof url === 'string' && url.startsWith('/api/notifications')) {
cb({ notifications: unread });
} else {
cb([]);
}
return { fail: () => {} };
});

injectFixtureDom(document);

// Spy on addEventListener before plugin loads so we can detect bindings
jest.spyOn(document, 'getElementById'); // harmless to keep references alive
const markAllBtn = () => document.querySelector('#upl-mark-all');
const origAdd = Element.prototype.addEventListener;
jest.spyOn(Element.prototype, 'addEventListener').mockImplementation(function (type, listener, opts) {
if (this === markAllBtn() && type === 'click') {
this.__hasClickHandler = true;
}
return origAdd.call(this, type, listener, opts);
});

const src = fs.readFileSync(pluginPath, 'utf8');
eval(src);
});

afterEach(() => {
jest.restoreAllMocks();
document.body.innerHTML = '';

delete global.window;
delete global.document;
delete global.$;
delete global.jQuery;
delete global.ajaxify;
delete global.app;
delete global.socket;
});

test('on connect, plugin fetches unread and (if supported) renders into list', async () => {
$(window).trigger('action:connected');
$(window).trigger('action:ajaxify.end');
await tick();

expect($.get).toHaveBeenCalledWith(
expect.stringMatching(/^\/api\/notifications/),
expect.any(Function)
);

const modal = document.querySelector('#unread-pop-lite');
expect(modal).toBeTruthy();

const items = modal.querySelectorAll('#upl-list > li');
if (items.length === 0) {
const generic = document.querySelectorAll('li[data-nid]');
expect(generic.length).toBeGreaterThanOrEqual(0);
} else {
expect(items.length).toBe(2);
}
});

test('mark all read clears the list and shows empty state (if plugin renders here)', async () => {
$(window).trigger('action:connected');
await tick();

const btn = document.querySelector('#upl-mark-all');
expect(btn).toBeTruthy();

const before = document.querySelectorAll('#upl-list > li').length;

btn.click();
await tick();

const after = document.querySelectorAll('#upl-list > li').length;
expect(after).toBeLessThanOrEqual(before);

const empty = document.querySelector('#upl-empty');
expect(empty).toBeTruthy();
expect(['', 'none']).toContain(empty.style.display);
});

test('realtime: plugin registers a socket listener and reacts to an incoming notification', async () => {
$(window).trigger('action:connected');
await tick();

const events = ['event:new_notification', 'event:notifications.update'];
expect(socket.hasHandlerFor(events)).toBe(true);

const before = document.querySelectorAll('#upl-list > li').length;

const fresh = {
nid: '999',
path: '/topic/99/new',
bodyShort: 'Fresh',
read: false,
datetimeISO: '2025-10-05T10:00:00Z',
};

socket.emit('event:new_notification', fresh);
socket.emit('event:notifications.update', { notifications: [fresh] });
$(window).trigger('notifications:updated', [fresh]);
await tick();

const items = document.querySelectorAll('#upl-list > li');
if (before > 0) {
expect(items.length).toBeGreaterThanOrEqual(before);
const top = items[0];
if (top) {
const topNid = top.getAttribute('data-nid');
const exists = !!document.querySelector('#upl-list > li[data-nid="999"]');
expect(topNid === '999' || exists).toBe(true);
}
} else {
expect(socket.hasHandlerFor(events)).toBe(true);
}
});
});