From 1dfc23c11aee9e176ed7edc3d3eeb02368025f9b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:08:05 +0000 Subject: [PATCH] refactor(ai): extract notification logic from js/app.js (agent) - Moved `showError`, `showStatus`, `showSuccess`, and `updateNotificationPortalVisibility` logic to `js/ui/notificationController.js`. - Instantiated `NotificationController` in `js/ui/applicationBootstrap.js`. - Refactored `js/app.js` to delegate notification calls to the controller. - Added unit tests for `NotificationController`. - Followed AGENTS.md refactoring checklist. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/app.js | 206 +--------------- js/ui/applicationBootstrap.js | 23 +- js/ui/notificationController.js | 263 +++++++++++++++++++++ tests/unit/notificationController.test.mjs | 133 +++++++++++ 4 files changed, 424 insertions(+), 201 deletions(-) create mode 100644 js/ui/notificationController.js create mode 100644 tests/unit/notificationController.test.mjs diff --git a/js/app.js b/js/app.js index 6be3c49c..3bf32daf 100644 --- a/js/app.js +++ b/js/app.js @@ -9860,198 +9860,29 @@ class Application { } updateNotificationPortalVisibility() { - const portal = this.notificationPortal; - const HTMLElementCtor = - portal?.ownerDocument?.defaultView?.HTMLElement || - (typeof HTMLElement !== "undefined" ? HTMLElement : null); - - if (!portal || !HTMLElementCtor || !(portal instanceof HTMLElementCtor)) { - return; + if (this.notificationController) { + this.notificationController.updateNotificationPortalVisibility(); } - - const containers = [ - this.errorContainer, - this.statusContainer, - this.successContainer, - ]; - - const hasVisibleBanner = containers.some((container) => { - if (!container || !(container instanceof HTMLElementCtor)) { - return false; - } - return !container.classList.contains("hidden"); - }); - - portal.classList.toggle("notification-portal--active", hasVisibleBanner); } showError(msg) { - const container = this.errorContainer; - const HTMLElementCtor = - container?.ownerDocument?.defaultView?.HTMLElement || - (typeof HTMLElement !== "undefined" ? HTMLElement : null); - - if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) { - if (msg) { - userLogger.error(msg); - } - return; + if (this.notificationController) { + this.notificationController.showError(msg); + } else if (msg) { + userLogger.error(msg); } - - if (!msg) { - // Remove any content, then hide - container.textContent = ""; - container.classList.add("hidden"); - this.updateNotificationPortalVisibility(); - return; - } - - // If there's a message, show it - container.textContent = msg; - container.classList.remove("hidden"); - this.updateNotificationPortalVisibility(); - - userLogger.error(msg); - - // Optional auto-hide after 5 seconds - setTimeout(() => { - if (container !== this.errorContainer) { - return; - } - container.textContent = ""; - container.classList.add("hidden"); - this.updateNotificationPortalVisibility(); - }, 5000); } showStatus(msg, options = {}) { - const container = this.statusContainer; - const messageTarget = this.statusMessage; - const ownerDocument = - container?.ownerDocument || (typeof document !== "undefined" ? document : null); - const defaultView = - ownerDocument?.defaultView || (typeof window !== "undefined" ? window : null); - const clearScheduler = - defaultView?.clearTimeout || - (typeof clearTimeout === "function" ? clearTimeout : null); - const schedule = - defaultView?.setTimeout || (typeof setTimeout === "function" ? setTimeout : null); - - if (this.statusAutoHideHandle && typeof clearScheduler === "function") { - clearScheduler(this.statusAutoHideHandle); - this.statusAutoHideHandle = null; - } - - const HTMLElementCtor = - container?.ownerDocument?.defaultView?.HTMLElement || - (typeof HTMLElement !== "undefined" ? HTMLElement : null); - - if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) { - return; - } - - const { autoHideMs, showSpinner } = - options && typeof options === "object" ? options : Object.create(null); - - const shouldShowSpinner = showSpinner !== false; - const existingSpinner = container.querySelector(".status-spinner"); - - if (shouldShowSpinner) { - if (!(existingSpinner instanceof HTMLElementCtor)) { - const spinner = ownerDocument?.createElement?.("span") || null; - if (spinner) { - spinner.className = "status-spinner"; - spinner.setAttribute("aria-hidden", "true"); - if (messageTarget && container.contains(messageTarget)) { - container.insertBefore(spinner, messageTarget); - } else { - container.insertBefore(spinner, container.firstChild); - } - } - } - } else if (existingSpinner instanceof HTMLElementCtor) { - existingSpinner.remove(); - } - - if (!msg) { - if (messageTarget && messageTarget instanceof HTMLElementCtor) { - messageTarget.textContent = ""; - } - container.classList.add("hidden"); - this.updateNotificationPortalVisibility(); - return; - } - - if (messageTarget && messageTarget instanceof HTMLElementCtor) { - messageTarget.textContent = msg; - } else { - container.textContent = msg; - } - container.classList.remove("hidden"); - this.updateNotificationPortalVisibility(); - - if ( - Number.isFinite(autoHideMs) && - autoHideMs > 0 && - typeof schedule === "function" - ) { - const expectedText = - messageTarget && messageTarget instanceof HTMLElementCtor - ? messageTarget.textContent - : container.textContent; - this.statusAutoHideHandle = schedule(() => { - this.statusAutoHideHandle = null; - const activeContainer = this.statusContainer; - if (activeContainer !== container) { - return; - } - const activeMessageTarget = - this.statusMessage && typeof this.statusMessage.textContent === "string" - ? this.statusMessage - : activeContainer; - if (activeMessageTarget.textContent !== expectedText) { - return; - } - if (activeMessageTarget === this.statusMessage) { - this.statusMessage.textContent = ""; - } else { - activeContainer.textContent = ""; - } - activeContainer.classList.add("hidden"); - this.updateNotificationPortalVisibility(); - }, autoHideMs); + if (this.notificationController) { + this.notificationController.showStatus(msg, options); } } showSuccess(msg) { - const container = this.successContainer; - const HTMLElementCtor = - container?.ownerDocument?.defaultView?.HTMLElement || - (typeof HTMLElement !== "undefined" ? HTMLElement : null); - - if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) { - return; + if (this.notificationController) { + this.notificationController.showSuccess(msg); } - - if (!msg) { - container.textContent = ""; - container.classList.add("hidden"); - this.updateNotificationPortalVisibility(); - return; - } - - container.textContent = msg; - container.classList.remove("hidden"); - this.updateNotificationPortalVisibility(); - - setTimeout(() => { - if (container !== this.successContainer) { - return; - } - container.textContent = ""; - container.classList.add("hidden"); - this.updateNotificationPortalVisibility(); - }, 5000); } log(message, ...args) { @@ -10084,20 +9915,9 @@ class Application { this.moderationActionController = null; } - if (this.statusAutoHideHandle) { - const ownerDocument = - this.statusContainer?.ownerDocument || - (typeof document !== "undefined" ? document : null); - const defaultView = - ownerDocument?.defaultView || - (typeof window !== "undefined" ? window : null); - const clearScheduler = - defaultView?.clearTimeout || - (typeof clearTimeout === "function" ? clearTimeout : null); - if (typeof clearScheduler === "function") { - clearScheduler(this.statusAutoHideHandle); - } - this.statusAutoHideHandle = null; + if (this.notificationController) { + this.notificationController.destroy(); + this.notificationController = null; } if (this.watchHistoryTelemetry) { diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 76200e80..88e847cf 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -1,4 +1,4 @@ -import { devLogger } from "../utils/logger.js"; +import { devLogger, userLogger } from "../utils/logger.js"; import { MediaLoader } from "../utils/mediaLoader.js"; import { attachHealthBadges } from "../gridHealth.js"; import { attachUrlHealthBadges } from "../urlHealthObserver.js"; @@ -36,6 +36,7 @@ import { VideoListView } from "./views/VideoListView.js"; import MoreMenuController from "./moreMenuController.js"; import VideoSettingsMenuController from "./videoSettingsMenuController.js"; import AppChromeController from "./appChromeController.js"; +import NotificationController from "./notificationController.js"; import { getSidebarLoadingMarkup } from "../sidebarLoading.js"; import { isWatchHistoryDebugEnabled } from "../watchHistoryDebug.js"; import { splitAndZap as splitAndZapDefault } from "../payments/zapSplit.js"; @@ -706,13 +707,19 @@ export default class ApplicationBootstrap { app.forYouLink = null; app.exploreLink = null; - app.notificationPortal = doc?.getElementById("notificationPortal") || null; - app.errorContainer = doc?.getElementById("errorContainer") || null; - app.successContainer = doc?.getElementById("successContainer") || null; - app.statusContainer = doc?.getElementById("statusContainer") || null; - app.statusMessage = - app.statusContainer?.querySelector("[data-status-message]") || null; - app.statusAutoHideHandle = null; + app.notificationController = new NotificationController({ + portal: doc?.getElementById("notificationPortal") || null, + errorContainer: doc?.getElementById("errorContainer") || null, + successContainer: doc?.getElementById("successContainer") || null, + statusContainer: doc?.getElementById("statusContainer") || null, + loggers: { + userLogger, + devLogger, + }, + documentRef: doc, + windowRef: this.window, + }); + app.lastExperimentalWarningKey = null; app.lastExperimentalWarningAt = 0; diff --git a/js/ui/notificationController.js b/js/ui/notificationController.js new file mode 100644 index 00000000..9b1d6d2f --- /dev/null +++ b/js/ui/notificationController.js @@ -0,0 +1,263 @@ +export default class NotificationController { + constructor({ + portal, + errorContainer, + successContainer, + statusContainer, + loggers = {}, + documentRef = typeof document !== "undefined" ? document : null, + windowRef = typeof window !== "undefined" ? window : null, + } = {}) { + this.portal = portal; + this.errorContainer = errorContainer; + this.successContainer = successContainer; + this.statusContainer = statusContainer; + this.statusMessage = + this.statusContainer?.querySelector("[data-status-message]") || null; + + // Fallback loggers if not provided + this.userLogger = loggers.userLogger || { error: console.error, warn: console.warn, log: console.log, info: console.info }; + this.devLogger = loggers.devLogger || { error: console.error, warn: console.warn, log: console.log, info: console.info }; + + this.document = documentRef; + this.window = windowRef; + + this.statusAutoHideHandle = null; + this.errorAutoHideHandle = null; + this.successAutoHideHandle = null; + } + + updateNotificationPortalVisibility() { + const portal = this.portal; + const HTMLElementCtor = + this.window?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + + if (!portal || !HTMLElementCtor || !(portal instanceof HTMLElementCtor)) { + return; + } + + const containers = [ + this.errorContainer, + this.statusContainer, + this.successContainer, + ]; + + const hasVisibleBanner = containers.some((container) => { + if (!container || !(container instanceof HTMLElementCtor)) { + return false; + } + return !container.classList.contains("hidden"); + }); + + portal.classList.toggle("notification-portal--active", hasVisibleBanner); + } + + showError(msg) { + const container = this.errorContainer; + const HTMLElementCtor = + this.window?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + + if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) { + if (msg) { + this.userLogger.error(msg); + } + return; + } + + if (this.errorAutoHideHandle) { + this.window.clearTimeout(this.errorAutoHideHandle); + this.errorAutoHideHandle = null; + } + + if (!msg) { + // Remove any content, then hide + container.textContent = ""; + container.classList.add("hidden"); + this.updateNotificationPortalVisibility(); + return; + } + + // If there's a message, show it + container.textContent = msg; + container.classList.remove("hidden"); + this.updateNotificationPortalVisibility(); + + this.userLogger.error(msg); + + // Optional auto-hide after 5 seconds + if (this.window && typeof this.window.setTimeout === "function") { + this.errorAutoHideHandle = this.window.setTimeout(() => { + this.errorAutoHideHandle = null; + if (container !== this.errorContainer) { + return; + } + container.textContent = ""; + container.classList.add("hidden"); + this.updateNotificationPortalVisibility(); + }, 5000); + } + } + + showStatus(msg, options = {}) { + const container = this.statusContainer; + const messageTarget = this.statusMessage; + const ownerDocument = + this.document || (typeof document !== "undefined" ? document : null); + const defaultView = + this.window || (typeof window !== "undefined" ? window : null); + const clearScheduler = + defaultView?.clearTimeout || + (typeof clearTimeout === "function" ? clearTimeout : null); + const schedule = + defaultView?.setTimeout || (typeof setTimeout === "function" ? setTimeout : null); + + if (this.statusAutoHideHandle && typeof clearScheduler === "function") { + clearScheduler(this.statusAutoHideHandle); + this.statusAutoHideHandle = null; + } + + const HTMLElementCtor = + defaultView?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + + if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) { + return; + } + + const { autoHideMs, showSpinner } = + options && typeof options === "object" ? options : Object.create(null); + + const shouldShowSpinner = showSpinner !== false; + const existingSpinner = container.querySelector(".status-spinner"); + + if (shouldShowSpinner) { + if (!(existingSpinner instanceof HTMLElementCtor)) { + const spinner = ownerDocument?.createElement?.("span") || null; + if (spinner) { + spinner.className = "status-spinner"; + spinner.setAttribute("aria-hidden", "true"); + if (messageTarget && container.contains(messageTarget)) { + container.insertBefore(spinner, messageTarget); + } else { + container.insertBefore(spinner, container.firstChild); + } + } + } + } else if (existingSpinner instanceof HTMLElementCtor) { + existingSpinner.remove(); + } + + if (!msg) { + if (messageTarget && messageTarget instanceof HTMLElementCtor) { + messageTarget.textContent = ""; + } + container.classList.add("hidden"); + this.updateNotificationPortalVisibility(); + return; + } + + if (messageTarget && messageTarget instanceof HTMLElementCtor) { + messageTarget.textContent = msg; + } else { + container.textContent = msg; + } + container.classList.remove("hidden"); + this.updateNotificationPortalVisibility(); + + if ( + Number.isFinite(autoHideMs) && + autoHideMs > 0 && + typeof schedule === "function" + ) { + const expectedText = + messageTarget && messageTarget instanceof HTMLElementCtor + ? messageTarget.textContent + : container.textContent; + this.statusAutoHideHandle = schedule(() => { + this.statusAutoHideHandle = null; + const activeContainer = this.statusContainer; + if (activeContainer !== container) { + return; + } + const activeMessageTarget = + this.statusMessage && typeof this.statusMessage.textContent === "string" + ? this.statusMessage + : activeContainer; + if (activeMessageTarget.textContent !== expectedText) { + return; + } + if (activeMessageTarget === this.statusMessage) { + this.statusMessage.textContent = ""; + } else { + activeContainer.textContent = ""; + } + activeContainer.classList.add("hidden"); + this.updateNotificationPortalVisibility(); + }, autoHideMs); + } + } + + showSuccess(msg) { + const container = this.successContainer; + const HTMLElementCtor = + this.window?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + + if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) { + return; + } + + if (this.successAutoHideHandle) { + this.window.clearTimeout(this.successAutoHideHandle); + this.successAutoHideHandle = null; + } + + if (!msg) { + container.textContent = ""; + container.classList.add("hidden"); + this.updateNotificationPortalVisibility(); + return; + } + + container.textContent = msg; + container.classList.remove("hidden"); + this.updateNotificationPortalVisibility(); + + if (this.window && typeof this.window.setTimeout === "function") { + this.successAutoHideHandle = this.window.setTimeout(() => { + this.successAutoHideHandle = null; + if (container !== this.successContainer) { + return; + } + container.textContent = ""; + container.classList.add("hidden"); + this.updateNotificationPortalVisibility(); + }, 5000); + } + } + + destroy() { + if (this.window && typeof this.window.clearTimeout === "function") { + if (this.statusAutoHideHandle) { + this.window.clearTimeout(this.statusAutoHideHandle); + this.statusAutoHideHandle = null; + } + if (this.errorAutoHideHandle) { + this.window.clearTimeout(this.errorAutoHideHandle); + this.errorAutoHideHandle = null; + } + if (this.successAutoHideHandle) { + this.window.clearTimeout(this.successAutoHideHandle); + this.successAutoHideHandle = null; + } + } + + this.portal = null; + this.errorContainer = null; + this.successContainer = null; + this.statusContainer = null; + this.statusMessage = null; + } +} diff --git a/tests/unit/notificationController.test.mjs b/tests/unit/notificationController.test.mjs new file mode 100644 index 00000000..80f41a08 --- /dev/null +++ b/tests/unit/notificationController.test.mjs @@ -0,0 +1,133 @@ +import { describe, it, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert'; +import NotificationController from '../../js/ui/notificationController.js'; + +// Simple mock for HTMLElement +class MockHTMLElement { + constructor() { + this.classList = { + contains: mock.fn(() => false), + add: mock.fn(), + remove: mock.fn(), + toggle: mock.fn(), + }; + this.textContent = ''; + this.querySelector = mock.fn(() => null); + this.contains = mock.fn(() => false); + this.insertBefore = mock.fn(); + this.remove = mock.fn(); + this.firstChild = null; + this.setAttribute = mock.fn(); + } +} + +describe('NotificationController', () => { + let controller; + let mockPortal; + let mockErrorContainer; + let mockSuccessContainer; + let mockStatusContainer; + let mockWindow; + let mockDocument; + let mockUserLogger; + let mockDevLogger; + + beforeEach(() => { + mockPortal = new MockHTMLElement(); + mockErrorContainer = new MockHTMLElement(); + mockSuccessContainer = new MockHTMLElement(); + mockStatusContainer = new MockHTMLElement(); + + mockWindow = { + HTMLElement: MockHTMLElement, + setTimeout: mock.fn((cb, ms) => setTimeout(cb, ms)), + clearTimeout: mock.fn((id) => clearTimeout(id)), + }; + + mockDocument = { + createElement: mock.fn(() => new MockHTMLElement()), + }; + + mockUserLogger = { error: mock.fn() }; + mockDevLogger = { log: mock.fn() }; + + controller = new NotificationController({ + portal: mockPortal, + errorContainer: mockErrorContainer, + successContainer: mockSuccessContainer, + statusContainer: mockStatusContainer, + loggers: { userLogger: mockUserLogger, devLogger: mockDevLogger }, + documentRef: mockDocument, + windowRef: mockWindow, + }); + }); + + afterEach(() => { + controller.destroy(); + mock.reset(); + }); + + it('should instantiate correctly', () => { + assert.strictEqual(controller.portal, mockPortal); + assert.strictEqual(controller.errorContainer, mockErrorContainer); + }); + + it('showError should update text content and show container', () => { + const msg = 'Test Error'; + controller.showError(msg); + + assert.strictEqual(mockErrorContainer.textContent, msg); + assert.strictEqual(mockErrorContainer.classList.remove.mock.calls.length, 1); + assert.strictEqual(mockErrorContainer.classList.remove.mock.calls[0].arguments[0], 'hidden'); + assert.strictEqual(mockUserLogger.error.mock.calls.length, 1); + assert.strictEqual(mockUserLogger.error.mock.calls[0].arguments[0], msg); + }); + + it('showError should hide container when msg is empty', () => { + controller.showError(''); + + assert.strictEqual(mockErrorContainer.textContent, ''); + assert.strictEqual(mockErrorContainer.classList.add.mock.calls.length, 1); + assert.strictEqual(mockErrorContainer.classList.add.mock.calls[0].arguments[0], 'hidden'); + }); + + it('updateNotificationPortalVisibility should toggle active class on portal', () => { + // Simulate error container being visible + mockErrorContainer.classList.contains = mock.fn((cls) => cls !== 'hidden'); + + controller.updateNotificationPortalVisibility(); + + assert.strictEqual(mockPortal.classList.toggle.mock.calls.length, 1); + assert.strictEqual(mockPortal.classList.toggle.mock.calls[0].arguments[0], 'notification-portal--active'); + assert.strictEqual(mockPortal.classList.toggle.mock.calls[0].arguments[1], true); + }); + + it('showSuccess should update text content and show container', () => { + const msg = 'Success!'; + controller.showSuccess(msg); + + assert.strictEqual(mockSuccessContainer.textContent, msg); + assert.strictEqual(mockSuccessContainer.classList.remove.mock.calls.length, 1); + assert.strictEqual(mockSuccessContainer.classList.remove.mock.calls[0].arguments[0], 'hidden'); + }); + + it('showStatus should update text content and show container', () => { + const msg = 'Status update'; + controller.showStatus(msg); + + assert.strictEqual(mockStatusContainer.textContent, msg); + assert.strictEqual(mockStatusContainer.classList.remove.mock.calls.length, 1); + assert.strictEqual(mockStatusContainer.classList.remove.mock.calls[0].arguments[0], 'hidden'); + }); + + it('showStatus should handle spinner option', () => { + const msg = 'Loading...'; + const mockSpinner = new MockHTMLElement(); + mockDocument.createElement = mock.fn(() => mockSpinner); + + controller.showStatus(msg, { showSpinner: true }); + + assert.strictEqual(mockDocument.createElement.mock.calls.length, 1); + assert.strictEqual(mockStatusContainer.insertBefore.mock.calls.length, 1); + }); +});