diff --git a/config/setupTests.js b/config/setupTests.js index 4b4ca6987..4242bda40 100644 --- a/config/setupTests.js +++ b/config/setupTests.js @@ -6,6 +6,7 @@ import 'babel-polyfill'; import '@testing-library/jest-dom'; import { expect } from '@jest/globals'; import * as matchers from '@testing-library/jest-dom/dist/matchers'; +import MutationObserver from 'mutation-observer'; // ensure the expect is picked up from jest not cypress global.expect = expect; @@ -13,12 +14,8 @@ global.expect = expect; global.expect.extend(matchers); configure({ adapter: new Adapter() }); global.SVGPathElement = function () {}; - -global.MutationObserver = class { - constructor(callback) {} - disconnect() {} - observe(element, initObject) {} -}; +// real MutationObserver polyfill for JSDOM +global.MutationObserver = MutationObserver; global.window.insights = { ...(window.insights || {}), diff --git a/package-lock.json b/package-lock.json index 08d08522d..0561eab1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "jest-canvas-mock": "^2.4.0", "jest-environment-jsdom": "^29.6.2", "lerna": "^5.6.2", + "mutation-observer": "^1.0.3", "node-sass-package-importer": "^5.3.2", "prettier": "^2.7.1", "redux-mock-store": "^1.5.4", @@ -30255,6 +30256,12 @@ "mustache": "bin/mustache" } }, + "node_modules/mutation-observer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mutation-observer/-/mutation-observer-1.0.3.tgz", + "integrity": "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==", + "dev": true + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -41989,10 +41996,12 @@ "version": "1.0.0", "license": "Apache-2.0", "devDependencies": { + "@redhat-cloud-services/types": "^1.0.3", "@types/react": "^18.0.0", "glob": "10.3.3" }, "peerDependencies": { + "@scalprum/react-core": "^0.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.0.0" @@ -49296,6 +49305,7 @@ "@redhat-cloud-services/chrome": { "version": "file:packages/chrome", "requires": { + "@redhat-cloud-services/types": "^1.0.3", "@types/react": "^18.0.0", "glob": "10.3.3" } @@ -66018,6 +66028,12 @@ "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==" }, + "mutation-observer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mutation-observer/-/mutation-observer-1.0.3.tgz", + "integrity": "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==", + "dev": true + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", diff --git a/package.json b/package.json index bc722f281..c8e8ce861 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "jest-canvas-mock": "^2.4.0", "jest-environment-jsdom": "^29.6.2", "lerna": "^5.6.2", + "mutation-observer": "^1.0.3", "node-sass-package-importer": "^5.3.2", "prettier": "^2.7.1", "redux-mock-store": "^1.5.4", diff --git a/packages/chrome/package.json b/packages/chrome/package.json index 7535a8a13..5d7e3af84 100644 --- a/packages/chrome/package.json +++ b/packages/chrome/package.json @@ -26,11 +26,13 @@ }, "homepage": "https://github.com/RedHatInsights/frontend-components/tree/master/packages/chrome#readme", "peerDependencies": { + "@scalprum/react-core": "^0.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.0.0" }, "devDependencies": { + "@redhat-cloud-services/types": "^1.0.3", "@types/react": "^18.0.0", "glob": "10.3.3" }, diff --git a/packages/chrome/src/ChromeProvider/ChromeProvider.test.tsx b/packages/chrome/src/ChromeProvider/ChromeProvider.test.tsx index d75fe431e..7e98aa5e7 100644 --- a/packages/chrome/src/ChromeProvider/ChromeProvider.test.tsx +++ b/packages/chrome/src/ChromeProvider/ChromeProvider.test.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Link, MemoryRouter, Route, Routes } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; +import { ScalprumContext } from '@scalprum/react-core'; +import { PluginStore } from '@openshift/dynamic-plugin-sdk'; import ChromeProvider from './ChromeProvider'; import * as fetch from '../utils/fetch'; @@ -31,17 +33,41 @@ describe('ChromeProvider', () => { expect(getSpy).toHaveBeenCalledWith('/api/chrome-service/v1/user'); }); - test('should post new data on pathname change', async () => { + test('should post new data on title change', async () => { getSpy.mockResolvedValueOnce([]); postSpy.mockResolvedValue(['/', '/bar']); + const DocumentMutator = () => { + useEffect(() => { + document.title = 'Foo title'; + }, []); + return null; + }; + // mock the initial document title + document.title = 'Initial title'; await act(async () => { render( - - - /foo/bar}> - - - + ({ + bundleTitle: 'bundle-title', + }), + }, + }, + }} + > + + + }> + /foo/bar}> + + + + ); }); // change location @@ -54,7 +80,11 @@ describe('ChromeProvider', () => { await flushPromises(); }); expect(postSpy).toHaveBeenCalledTimes(2); - expect(postSpy).toHaveBeenLastCalledWith('/api/chrome-service/v1/last-visited', { pathname: '/foo/bar', title: '', bundle: 'bundle-title' }); + expect(postSpy).toHaveBeenLastCalledWith('/api/chrome-service/v1/last-visited', { + pathname: '/foo/bar', + title: 'Foo title', + bundle: 'bundle-title', + }); }); test('should not update state on mount if received error response', async () => { @@ -66,7 +96,22 @@ describe('ChromeProvider', () => { await act(async () => { await render( - + ({ + bundleTitle: 'bundle-title', + }), + }, + }, + }} + > + + ); }); diff --git a/packages/chrome/src/ChromeProvider/ChromeProvider.tsx b/packages/chrome/src/ChromeProvider/ChromeProvider.tsx index b576a6236..7cef26273 100644 --- a/packages/chrome/src/ChromeProvider/ChromeProvider.tsx +++ b/packages/chrome/src/ChromeProvider/ChromeProvider.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; +import { useScalprum } from '@scalprum/react-core'; +import { ChromeAPI } from '@redhat-cloud-services/types'; import { useLocation } from 'react-router-dom'; import { ChromeContext } from '../ChromeContext'; @@ -7,20 +9,61 @@ import { IDENTITY_URL, LAST_VISITED_URL, get, post } from '../utils/fetch'; const getUserIdentity = () => get(IDENTITY_URL); -const useLastPageVisitedUploader = (providerState: ReturnType, bundle = '') => { +const useLastPageVisitedUploader = (providerState: ReturnType) => { + const scalprum = useScalprum<{ initialized: boolean; api: { chrome: ChromeAPI } }>(); const { pathname } = useLocation(); + const postData = async (pathname: string, title: string, bundle: string) => { + try { + const data = await post(LAST_VISITED_URL, { + pathname, + title, + bundle, + }); + providerState.setLastVisited(data); + } catch (error) { + console.error('Unable to update last visited pages!', error); + } + }; useEffect(() => { - post(LAST_VISITED_URL, { - pathname, - title: document.title, - bundle, - }) - .then((data) => providerState.setLastVisited(data)) - .catch((error) => console.error('Unable to update last visited pages!', error)); + let titleObserver: MutationObserver | undefined; + let prevTitle: string | null; + const titleTarget = document.querySelector('title'); + if (titleTarget) { + prevTitle = titleTarget.textContent; + // initial api call on mount + postData(pathname, prevTitle ?? '', scalprum.api.chrome.getBundleData().bundleTitle); + /** + * Use Mutation observer to trigger the updates. + * Using the observer will ensure the last visited pages gets updated on document title change rather than just location change. + * The chrome service uses pathname as identifier and updates title according. + * Multiple calls with the same pathname and different title will ensure that the latest correct title is assigned to a pathname. * + * */ + titleObserver = new MutationObserver((mutations) => { + // grab text from the title element + const currentTitle = mutations[0]?.target.textContent; + // trigger only if the titles are different + if (typeof currentTitle === 'string' && currentTitle !== prevTitle) { + try { + prevTitle = currentTitle; + postData(pathname, currentTitle, scalprum.api.chrome.getBundleData().bundleTitle); + } catch (error) { + // catch sync errors + console.error('Unable to update last visited pages!', error); + } + } + }); + titleObserver.observe(titleTarget, { + // observe only the children + childList: true, + }); + } + return () => { + titleObserver?.disconnect(); + }; }, [pathname]); }; -const ChromeProvider: React.FC> = ({ children, bundle }) => { +const ChromeProvider: React.FC = ({ children }) => { const isMounted = useRef(false); const [initialRequest, setInitialRequest] = useState(false); const providerState = useRef>(); @@ -28,7 +71,7 @@ const ChromeProvider: React.FC> = ( providerState.current = chromeState(); } - useLastPageVisitedUploader(providerState.current, bundle); + useLastPageVisitedUploader(providerState.current); useEffect(() => { isMounted.current = true;