From 9d616de5dfb8984eda55f336d045e678c3e0e788 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 16 Dec 2024 23:26:51 -0500 Subject: [PATCH] feat(sdk-analytics) comments done --- core-web/libs/sdk/analytics/README.md | 20 +-- core-web/libs/sdk/analytics/jest.config.ts | 9 +- core-web/libs/sdk/analytics/package.json | 7 +- core-web/libs/sdk/analytics/project.json | 5 - .../dot-content-analytics.spec.ts | 40 +++-- .../shared/dot-content-analytics.http.ts | 9 +- .../shared/dot-content-analytics.model.ts | 2 +- .../dot-content-analytics.utils.spec.ts | 142 ++++++++++++------ .../shared/dot-content-analytics.utils.ts | 10 +- .../DotContentAnalyticsProvider.spec.tsx | 86 +++++++++++ .../DotContentAnalyticsProvider.tsx | 4 +- .../DotContentAnalyticsContext.spec.tsx | 17 ++- .../react/hook/useContentAnalytics.spec.tsx | 72 +++++++++ .../src/lib/react/hook/useContentAnalytics.ts | 3 +- .../lib/react/hook/useRouteTracker.spec.tsx | 96 ++++++++++++ .../src/lib/react/hook/useRouterTracker.ts | 26 +++- core-web/libs/sdk/analytics/test-setup.ts | 4 + core-web/libs/sdk/analytics/tsconfig.json | 11 +- core-web/libs/sdk/analytics/tsconfig.lib.json | 21 +-- .../src/components/content-types/activity.js | 3 - examples/nextjs/src/components/my-page.js | 11 -- 21 files changed, 464 insertions(+), 134 deletions(-) create mode 100644 core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.spec.tsx create mode 100644 core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.spec.tsx create mode 100644 core-web/libs/sdk/analytics/src/lib/react/hook/useRouteTracker.spec.tsx create mode 100644 core-web/libs/sdk/analytics/test-setup.ts diff --git a/core-web/libs/sdk/analytics/README.md b/core-web/libs/sdk/analytics/README.md index b42f5e4e88b1..544241bc9454 100644 --- a/core-web/libs/sdk/analytics/README.md +++ b/core-web/libs/sdk/analytics/README.md @@ -29,7 +29,7 @@ Or include the script in your HTML page: First, import the provider: ```tsx -import { DotContentAnalyticsProvider } from '@dotcms/analytics'; +import { DotContentAnalyticsProvider } from '@dotcms/analytics/react'; ``` Wrap your application with the `DotContentAnalyticsProvider`: @@ -56,21 +56,15 @@ function App() { Use the `useAnalyticsTracker` hook to track custom events: ```tsx +import { useAnalyticsTracker } from '@dotcms/analytics/react'; + function Activity({ title, urlTitle }) { const { track } = useAnalyticsTracker(); - const handleClick = () => { - // First parameter: custom event name to identify the action - // Second parameter: object with properties you want to track - track('product-detail-click', { - productTitle: title, - productUrl: urlTitle, - clickedElement: 'detail-button', - timestamp: new Date().toISOString() - }); - }; - - return ; + // First parameter: custom event name to identify the action + // Second parameter: object with properties you want to track + + return ; } ``` diff --git a/core-web/libs/sdk/analytics/jest.config.ts b/core-web/libs/sdk/analytics/jest.config.ts index 25e2420fc76d..65639983172d 100644 --- a/core-web/libs/sdk/analytics/jest.config.ts +++ b/core-web/libs/sdk/analytics/jest.config.ts @@ -14,15 +14,16 @@ if (swcJestConfig.swcrc === undefined) { // Uncomment if using global setup/teardown files being transformed via swc // https://nx.dev/nx-api/jest/documents/overview#global-setupteardown-with-nx-libraries // jest needs EsModule Interop to find the default exported setup/teardown functions -// swcJestConfig.module.noInterop = false; +swcJestConfig.module.noInterop = false; export default { displayName: 'analytics', preset: '../../../jest.preset.js', transform: { - '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig] + '^.+\\.[tj]sx?$': ['@swc/jest', swcJestConfig] }, - moduleFileExtensions: ['ts', 'js', 'html'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], testEnvironment: 'jsdom', - coverageDirectory: '../../../coverage/libs/sdk/analytics' + coverageDirectory: '../../../coverage/libs/sdk/analytics', + setupFilesAfterEnv: ['/test-setup.ts'] }; diff --git a/core-web/libs/sdk/analytics/package.json b/core-web/libs/sdk/analytics/package.json index cef4094b8ee4..63269b2defeb 100644 --- a/core-web/libs/sdk/analytics/package.json +++ b/core-web/libs/sdk/analytics/package.json @@ -22,7 +22,8 @@ "homepage": "https://github.com/dotCMS/core/tree/main/core-web/libs/sdk/analytics/README.md", "dependencies": { "analytics": "^0.8.14", - "vite": "~5.0.0" + "vite": "~5.0.0", + "@testing-library/jest-dom": "^6.1.6" }, "peerDependencies": { "react": "^18.2.0" @@ -31,8 +32,8 @@ "module": "./index.esm.js", "exports": { "./react": { - "import": "./lib/react/index.js", - "types": "./lib/react/index.d.ts" + "import": "./react/index.js", + "types": "./src/lib/react/index.d.ts" } } } diff --git a/core-web/libs/sdk/analytics/project.json b/core-web/libs/sdk/analytics/project.json index 9b51ff62cd0d..fd215bf37316 100644 --- a/core-web/libs/sdk/analytics/project.json +++ b/core-web/libs/sdk/analytics/project.json @@ -41,11 +41,6 @@ "project": "libs/sdk/analytics/package.json" } }, - "nx-release-publish": { - "options": { - "packageRoot": "dist/{projectRoot}" - } - }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], diff --git a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/dot-content-analytics.spec.ts b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/dot-content-analytics.spec.ts index facece465769..bf269edad8c4 100644 --- a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/dot-content-analytics.spec.ts +++ b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/dot-content-analytics.spec.ts @@ -3,17 +3,18 @@ import Analytics from 'analytics'; import { DotContentAnalytics } from './dot-content-analytics'; -import { dotAnalyticsPlugin } from './plugin/dot-analytics.plugin'; +import { dotAnalytics } from './plugin/dot-analytics.plugin'; +import { DotContentAnalyticsConfig } from './shared/dot-content-analytics.model'; // Mock the analytics library jest.mock('analytics'); jest.mock('./plugin/dot-analytics.plugin'); describe('DotAnalytics', () => { - const mockConfig = { + const mockConfig: DotContentAnalyticsConfig = { debug: false, server: 'http://test.com', - key: 'test-key', + apiKey: 'test-key', autoPageView: false }; @@ -43,17 +44,34 @@ describe('DotAnalytics', () => { it('should initialize analytics with correct config', async () => { const instance = DotContentAnalytics.getInstance(mockConfig); const mockAnalytics = {}; + (Analytics as jest.Mock).mockReturnValue(mockAnalytics); - (dotAnalyticsPlugin as jest.Mock).mockReturnValue({ name: 'mock-plugin' }); + (dotAnalytics as jest.Mock).mockReturnValue({ name: 'mock-plugin' }); + + // Mock del enricher plugin + jest.mock('./plugin/dot-analytics.enricher.plugin', () => ({ + dotAnalyticsEnricherPlugin: { + name: 'enrich-dot-analytics', + 'page:dot-analytics': jest.fn(), + 'track:dot-analytics': jest.fn() + } + })); await instance.ready(); expect(Analytics).toHaveBeenCalledWith({ app: 'dotAnalytics', debug: false, - plugins: [{ name: 'mock-plugin' }] + plugins: [ + { + name: 'enrich-dot-analytics', + 'page:dot-analytics': expect.any(Function), + 'track:dot-analytics': expect.any(Function) + }, + { name: 'mock-plugin' } + ] }); - expect(dotAnalyticsPlugin).toHaveBeenCalledWith(mockConfig); + expect(dotAnalytics).toHaveBeenCalledWith(mockConfig); }); it('should only initialize once', async () => { @@ -72,20 +90,20 @@ describe('DotAnalytics', () => { throw error; }); - // eslint-disable-next-line @typescript-eslint/no-empty-function - const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => { + // Do nothing + }); try { await instance.ready(); + fail('Should have thrown an error'); } catch (e) { expect(e).toEqual(error); expect(console.error).toHaveBeenCalledWith( - 'Failed to initialize DotAnalytics:', - error + '[dotCMS DotContentAnalytics] Failed to initialize: Error: Init failed' ); } - // Restore console.error consoleErrorMock.mockRestore(); }); }); diff --git a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.http.ts b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.http.ts index 6974c59d423c..7120817a7352 100644 --- a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.http.ts +++ b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.http.ts @@ -10,7 +10,7 @@ import { DotContentAnalyticsConfig, ServerEvent } from './dot-content-analytics. export const sendAnalyticsEventToServer = async ( data: Record, options: DotContentAnalyticsConfig -): Promise => { +): Promise => { const serverEvent: ServerEvent = { ...data, timestamp: new Date().toISOString(), @@ -28,13 +28,10 @@ export const sendAnalyticsEventToServer = async ( body: JSON.stringify(serverEvent) }); - if (response.ok) { - return response; - } else { - throw new Error(`${response.status}`); + if (!response.ok) { + console.error(`DotAnalytics: Server responded with status ${response.status}`); } } catch (error) { console.error('DotAnalytics: Error sending event:', error); - throw error; } }; diff --git a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.model.ts b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.model.ts index 43604bfb503f..9262303143d1 100644 --- a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.model.ts +++ b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.model.ts @@ -23,7 +23,7 @@ export interface DotContentAnalyticsConfig { /** * Automatically track page views when set to true. */ - autoPageView: boolean; + autoPageView?: boolean; /** * The API key for authenticating with the Analytics service. diff --git a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.spec.ts b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.spec.ts index 3fb5dc81be23..caf813a21e4a 100644 --- a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.spec.ts +++ b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.spec.ts @@ -1,11 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { ANALYTICS_SOURCE_TYPE } from './dot-content-analytics.constants'; import { - createAnalyticsPageViewData, + defaultRedirectFn, extractUTMParameters, getAnalyticsScriptTag, - getDataAnalyticsAttributes + getBrowserEventData, + getDataAnalyticsAttributes, + isInsideEditor } from './dot-content-analytics.utils'; describe('Analytics Utils', () => { @@ -19,7 +21,8 @@ describe('Analytics Utils', () => { hostname: 'example.com', protocol: 'https:', hash: '#section1', - search: '?param=1' + search: '?param=1', + origin: 'https://example.com' } as Location; // Clean up any previous script tags @@ -29,14 +32,12 @@ describe('Analytics Utils', () => { describe('getAnalyticsScriptTag', () => { it('should return analytics script tag when present', () => { const script = document.createElement('script'); - script.setAttribute('data-analytics-server', 'https://analytics.example.com'); + script.setAttribute('data-analytics-key', 'test-key'); document.body.appendChild(script); const result = getAnalyticsScriptTag(); expect(result).toBeTruthy(); - expect(result.getAttribute('data-analytics-server')).toBe( - 'https://analytics.example.com' - ); + expect(result.getAttribute('data-analytics-key')).toBe('test-key'); }); it('should throw error when analytics script tag is not found', () => { @@ -47,7 +48,7 @@ describe('Analytics Utils', () => { describe('getDataAnalyticsAttributes', () => { beforeEach(() => { const script = document.createElement('script'); - script.setAttribute('data-analytics-server', 'https://analytics.example.com'); + script.setAttribute('data-analytics-key', 'test-key'); document.body.appendChild(script); }); @@ -55,43 +56,43 @@ describe('Analytics Utils', () => { const result = getDataAnalyticsAttributes(mockLocation); expect(result).toEqual({ - server: 'https://analytics.example.com', + server: mockLocation.origin, debug: false, autoPageView: false, - key: '' + apiKey: 'test-key' }); }); it('should enable debug when debug attribute exists', () => { - const script = document.querySelector('script[data-analytics-server]'); + const script = document.querySelector('script[data-analytics-key]'); script?.setAttribute('data-analytics-debug', ''); const result = getDataAnalyticsAttributes(mockLocation); expect(result).toEqual({ - server: 'https://analytics.example.com', + server: mockLocation.origin, debug: true, autoPageView: false, - key: '' + apiKey: 'test-key' }); }); it('should disable autoPageView when auto-page-view attribute exists', () => { - const script = document.querySelector('script[data-analytics-server]'); + const script = document.querySelector('script[data-analytics-key]'); script?.setAttribute('data-analytics-auto-page-view', ''); const result = getDataAnalyticsAttributes(mockLocation); expect(result).toEqual({ - server: 'https://analytics.example.com', + server: mockLocation.origin, debug: false, autoPageView: true, - key: '' + apiKey: 'test-key' }); }); it('should handle all attributes together', () => { - const script = document.querySelector('script[data-analytics-server]'); + const script = document.querySelector('script[data-analytics-key]'); script?.setAttribute('data-analytics-debug', ''); script?.setAttribute('data-analytics-auto-page-view', ''); script?.setAttribute('data-analytics-key', 'test-key'); @@ -99,30 +100,40 @@ describe('Analytics Utils', () => { const result = getDataAnalyticsAttributes(mockLocation); expect(result).toEqual({ - server: 'https://analytics.example.com', + server: mockLocation.origin, debug: true, autoPageView: true, - key: 'test-key' + apiKey: 'test-key' }); }); it('should handle key attribute', () => { - const script = document.querySelector('script[data-analytics-server]'); - script?.setAttribute('data-analytics-key', 'test-key'); + const script = document.querySelector('script[data-analytics-key]'); + script?.setAttribute('data-analytics-key', 'test-key2'); const result = getDataAnalyticsAttributes(mockLocation); expect(result).toEqual({ - server: 'https://analytics.example.com', + server: mockLocation.origin, debug: false, autoPageView: false, - key: 'test-key' + apiKey: 'test-key2' }); }); }); describe('createAnalyticsPageViewData', () => { beforeEach(() => { + mockLocation = { + href: 'https://example.com/page', + pathname: '/page', + hostname: 'example.com', + protocol: 'https:', + hash: '#section1', + search: '?param=1', + origin: 'https://example.com' + } as Location; + // Mock window properties Object.defineProperty(window, 'innerWidth', { value: 1024 }); Object.defineProperty(window, 'innerHeight', { value: 768 }); @@ -140,11 +151,11 @@ describe('Analytics Utils', () => { }); it('should create page view data with basic properties', () => { - const result = createAnalyticsPageViewData('page_view', mockLocation); + const result = getBrowserEventData(mockLocation); expect(result).toEqual( expect.objectContaining({ - event_type: 'page_view', + local_tz_offset: 300, page_title: 'Test Page', doc_path: '/page', doc_host: 'example.com', @@ -153,28 +164,13 @@ describe('Analytics Utils', () => { doc_search: '?param=1', screen_resolution: '1920x1080', vp_size: '1024x768', - user_agent: 'test-agent', + userAgent: 'test-agent', user_language: 'es-ES', doc_encoding: 'UTF-8', - referer: 'https://referrer.com', - src: ANALYTICS_SOURCE_TYPE + referrer: 'https://referrer.com' }) ); }); - - it('should handle UTM parameters correctly', () => { - mockLocation.search = - '?utm_source=test&utm_medium=email&utm_campaign=welcome&utm_id=123'; - - const result = createAnalyticsPageViewData('page_view', mockLocation); - - expect(result.utm).toEqual({ - source: 'test', - medium: 'email', - campaign: 'welcome', - id: '123' - }); - }); }); describe('extractUTMParameters', () => { @@ -231,4 +227,64 @@ describe('Analytics Utils', () => { }); }); }); + + describe('defaultRedirectFn', () => { + const originalLocation = window.location; + + beforeEach(() => { + // Mock window.location + delete (window as any).location; + window.location = { ...originalLocation }; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('should update window.location.href with provided URL', () => { + const testUrl = 'https://test.com'; + defaultRedirectFn(testUrl); + expect(window.location.href).toBe(testUrl); + }); + }); + + describe('isInsideEditor', () => { + const originalWindow = window; + + beforeEach(() => { + // Reset window to original state before each test + (global as any).window = { ...originalWindow }; + }); + + afterEach(() => { + // Restore window object + (global as any).window = originalWindow; + }); + + it('should return false when window is undefined', () => { + (global as any).window = undefined; + expect(isInsideEditor()).toBe(false); + }); + + it('should return false when window.parent is undefined', () => { + (window as any).parent = undefined; + expect(isInsideEditor()).toBe(false); + }); + + it('should return false when window.parent equals window', () => { + window.parent = window; + expect(isInsideEditor()).toBe(false); + }); + + it('should return true when window.parent differs from window', () => { + // Create a new window-like object that's definitely different from window + const mockParent = { ...window, someUniqueProperty: true }; + Object.defineProperty(window, 'parent', { + value: mockParent, + writable: true, + configurable: true + }); + expect(isInsideEditor()).toBe(true); + }); + }); }); diff --git a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.ts b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.ts index d25130e2fc33..bbc8f24ce6fd 100644 --- a/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.ts +++ b/core-web/libs/sdk/analytics/src/lib/dotAnalytics/shared/dot-content-analytics.utils.ts @@ -94,9 +94,13 @@ export const defaultRedirectFn = (href: string) => (window.location.href = href) * @returns {boolean} - True if inside the editor, false otherwise. */ export const isInsideEditor = (): boolean => { - if (typeof window === 'undefined') { + try { + if (typeof window === 'undefined') return false; + + if (!window.parent) return false; + + return window.parent !== window; + } catch (e) { return false; } - - return window.parent !== window; }; diff --git a/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.spec.tsx b/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.spec.tsx new file mode 100644 index 000000000000..4be8e934f461 --- /dev/null +++ b/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.spec.tsx @@ -0,0 +1,86 @@ +import { jest } from '@jest/globals'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { DotContentAnalyticsProvider } from './DotContentAnalyticsProvider'; + +import { DotContentAnalytics } from '../../dotAnalytics/dot-content-analytics'; +import { DotContentAnalyticsConfig } from '../../dotAnalytics/shared/dot-content-analytics.model'; + +// Mock dependencies +jest.mock('../../dotAnalytics/dot-content-analytics'); +jest.mock('../hook/useRouterTracker'); + +describe('DotContentAnalyticsProvider', () => { + const mockConfig: DotContentAnalyticsConfig = { + apiKey: 'test-key', + server: 'test-server', + debug: false + }; + + const mockDotContentAnalyticsInstance = { + ready: jest.fn<() => Promise>().mockResolvedValue(), + pageView: jest.fn(), + track: jest.fn(), + getInstance: jest.fn<() => Promise>().mockResolvedValue(true) + } as Partial; + + beforeEach(() => { + jest.clearAllMocks(); + DotContentAnalytics.getInstance = jest + .fn() + .mockReturnValue(mockDotContentAnalyticsInstance); + }); + + it('should initialize analytics instance with config', () => { + render( + +
Test Child
+
+ ); + + expect(DotContentAnalytics.getInstance).toHaveBeenCalledWith(mockConfig); + }); + + it('should call ready() on mount', async () => { + render( + +
Test Child
+
+ ); + + await waitFor(() => { + expect(mockDotContentAnalyticsInstance.ready).toHaveBeenCalled(); + }); + }); + + it('should render children', () => { + const { getByText } = render( + +
Test Child
+
+ ); + + expect(getByText('Test Child')).toBeInTheDocument(); + }); + + it('should handle ready() rejection', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { + // Do nothing + }); + const error = new Error('Test error'); + mockDotContentAnalyticsInstance.ready.mockRejectedValueOnce(error); + + render( + +
Test Child
+
+ ); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error initializing analytics:', error); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.tsx b/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.tsx index c844eb27f4ec..cef7d545c739 100644 --- a/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.tsx +++ b/core-web/libs/sdk/analytics/src/lib/react/components/DotContentAnalyticsProvider.tsx @@ -3,7 +3,7 @@ import { ReactElement, ReactNode, useEffect, useMemo } from 'react'; import { DotContentAnalytics } from '../../dotAnalytics/dot-content-analytics'; import { DotContentAnalyticsConfig } from '../../dotAnalytics/shared/dot-content-analytics.model'; import DotContentAnalyticsContext from '../contexts/DotContentAnalyticsContext'; -import { useRouteTracker } from '../hook/useRouterTracker'; +import { useRouterTracker } from '../hook/useRouterTracker'; interface DotContentAnalyticsProviderProps { children?: ReactNode; @@ -33,7 +33,7 @@ export const DotContentAnalyticsProvider = ({ }); }, [instance]); - useRouteTracker(instance); + useRouterTracker(instance); return ( diff --git a/core-web/libs/sdk/analytics/src/lib/react/contexts/DotContentAnalyticsContext.spec.tsx b/core-web/libs/sdk/analytics/src/lib/react/contexts/DotContentAnalyticsContext.spec.tsx index 257c60c17c82..726935fb65f8 100644 --- a/core-web/libs/sdk/analytics/src/lib/react/contexts/DotContentAnalyticsContext.spec.tsx +++ b/core-web/libs/sdk/analytics/src/lib/react/contexts/DotContentAnalyticsContext.spec.tsx @@ -1,16 +1,23 @@ import { jest } from '@jest/globals'; +import '@testing-library/jest-dom'; import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; import { ReactNode, useContext } from 'react'; import DotContentAnalyticsContext from './DotContentAnalyticsContext'; import { DotContentAnalytics } from '../../dotAnalytics/dot-content-analytics'; -jest.mock('../dot-content-analytics', () => { - return jest.fn().mockImplementation(() => { - return {}; - }); -}); +jest.mock('../../dotAnalytics/dot-content-analytics', () => ({ + DotContentAnalytics: { + getInstance: jest.fn().mockImplementation(() => ({ + track: jest.fn(), + ready: jest.fn(), + logger: console, + initialized: false + })) + } +})); describe('useDotContentAnalyticsContext', () => { it('returns the context value null', () => { diff --git a/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.spec.tsx b/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.spec.tsx new file mode 100644 index 000000000000..f72627ee2471 --- /dev/null +++ b/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.spec.tsx @@ -0,0 +1,72 @@ +import { jest } from '@jest/globals'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { ReactNode } from 'react'; + +import { useContentAnalytics } from './useContentAnalytics'; + +import DotContentAnalyticsContext from '../contexts/DotContentAnalyticsContext'; + +interface WrapperProps { + children: ReactNode; +} + +const mockTrack = jest.fn(); + +const wrapper = ({ children }: WrapperProps) => ( + + {children} + +); + +const mockIsInsideEditor = jest.fn(); + +jest.mock('../../dotAnalytics/shared/dot-content-analytics.utils', () => ({ + isInsideEditor: mockIsInsideEditor +})); + +describe('useContentAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should track with timestamp when outside editor', () => { + mockIsInsideEditor.mockImplementation(() => false); + + const mockDate = '2024-01-01T00:00:00.000Z'; + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(mockDate); + + const { result } = renderHook(() => useContentAnalytics(), { wrapper }); + result.current.track('test-event', { data: 'test' }); + + expect(mockTrack).toHaveBeenCalledWith('test-event', { + data: 'test', + timestamp: mockDate + }); + }); + + it('should handle undefined payload', () => { + mockIsInsideEditor.mockImplementation(() => false); + + const mockDate = '2024-01-01T00:00:00.000Z'; + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(mockDate); + + const { result } = renderHook(() => useContentAnalytics(), { wrapper }); + result.current.track('test-event'); + + expect(mockTrack).toHaveBeenCalledWith('test-event', { + timestamp: mockDate + }); + }); +}); diff --git a/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.ts b/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.ts index d22b8376dc6c..8e377fb7d6a0 100644 --- a/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.ts +++ b/core-web/libs/sdk/analytics/src/lib/react/hook/useContentAnalytics.ts @@ -12,7 +12,6 @@ import DotContentAnalyticsContext from '../contexts/DotContentAnalyticsContext'; */ export const useContentAnalytics = (): DotContentAnalyticsCustomHook => { const instance = useContext(DotContentAnalyticsContext); - const insideEditor = isInsideEditor(); return { /** @@ -22,7 +21,7 @@ export const useContentAnalytics = (): DotContentAnalyticsCustomHook => { * @param {object} payload - Additional data to include with the event */ track: (eventName: string, payload: Record = {}) => { - if (instance?.track && !insideEditor) { + if (instance?.track && !isInsideEditor()) { instance.track(eventName, { ...payload, timestamp: new Date().toISOString() diff --git a/core-web/libs/sdk/analytics/src/lib/react/hook/useRouteTracker.spec.tsx b/core-web/libs/sdk/analytics/src/lib/react/hook/useRouteTracker.spec.tsx new file mode 100644 index 000000000000..ad76e02ca0c2 --- /dev/null +++ b/core-web/libs/sdk/analytics/src/lib/react/hook/useRouteTracker.spec.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; + +import { useRouterTracker } from './useRouterTracker'; + +import { DotContentAnalytics } from '../../dotAnalytics/dot-content-analytics'; + +describe('useRouterTracker', () => { + let mockAnalytics: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockAnalytics = { + pageView: jest.fn() + } as unknown as jest.Mocked; + + Object.defineProperty(window, 'location', { + value: { + pathname: '/initial-path', + search: '' + }, + writable: true, + configurable: true + }); + }); + + it('should not track when analytics is null', () => { + renderHook(() => useRouterTracker(null)); + expect(mockAnalytics.pageView).not.toHaveBeenCalled(); + }); + + it('should track page view when path changes', () => { + renderHook(() => useRouterTracker(mockAnalytics)); + + expect(mockAnalytics.pageView).toHaveBeenCalledTimes(1); + + act(() => { + Object.defineProperty(window, 'location', { + value: { + pathname: '/new-path', + search: '' + }, + writable: true, + configurable: true + }); + + window.dispatchEvent(new Event('popstate')); + }); + + expect(mockAnalytics.pageView).toHaveBeenCalledTimes(2); + }); + + it('should not track page view when path remains the same', () => { + renderHook(() => useRouterTracker(mockAnalytics)); + + expect(mockAnalytics.pageView).toHaveBeenCalledTimes(1); + + act(() => { + Object.defineProperty(window, 'location', { + value: { + pathname: '/initial-path', + search: '' + }, + writable: true, + configurable: true + }); + + window.dispatchEvent(new Event('popstate')); + }); + + expect(mockAnalytics.pageView).toHaveBeenCalledTimes(1); + }); + + it('should cleanup event listener on unmount', () => { + const { unmount } = renderHook(() => useRouterTracker(mockAnalytics)); + + expect(mockAnalytics.pageView).toHaveBeenCalledTimes(1); + + unmount(); + + act(() => { + Object.defineProperty(window, 'location', { + value: { + pathname: '/new-path', + search: '' + }, + writable: true, + configurable: true + }); + + window.dispatchEvent(new Event('popstate')); + }); + + expect(mockAnalytics.pageView).toHaveBeenCalledTimes(1); + }); +}); diff --git a/core-web/libs/sdk/analytics/src/lib/react/hook/useRouterTracker.ts b/core-web/libs/sdk/analytics/src/lib/react/hook/useRouterTracker.ts index 02adf329c473..23f9b2d8368e 100644 --- a/core-web/libs/sdk/analytics/src/lib/react/hook/useRouterTracker.ts +++ b/core-web/libs/sdk/analytics/src/lib/react/hook/useRouterTracker.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { DotContentAnalytics } from '../../dotAnalytics/dot-content-analytics'; +import { isInsideEditor } from '../../dotAnalytics/shared/dot-content-analytics.utils'; /** * Internal custom hook that handles analytics page view tracking. @@ -9,17 +10,30 @@ import { DotContentAnalytics } from '../../dotAnalytics/dot-content-analytics'; * @returns {void} * */ -export function useRouteTracker(analytics: DotContentAnalytics | null) { +export function useRouterTracker(analytics: DotContentAnalytics | null) { const lastPathRef = useRef(null); useEffect(() => { if (!analytics) return; - const currentPath = window.location.pathname; - - if (currentPath !== lastPathRef.current) { - lastPathRef.current = currentPath; - analytics.pageView(); + function handleRouteChange() { + const currentPath = window.location.pathname; + if (currentPath !== lastPathRef.current && !isInsideEditor()) { + lastPathRef.current = currentPath; + analytics!.pageView(); + } } + + // Track initial page view + handleRouteChange(); + + // Listen for navigation events + window.addEventListener('popstate', handleRouteChange); + window.addEventListener('beforeunload', handleRouteChange); + + return () => { + window.removeEventListener('popstate', handleRouteChange); + window.removeEventListener('beforeunload', handleRouteChange); + }; }, [analytics]); } diff --git a/core-web/libs/sdk/analytics/test-setup.ts b/core-web/libs/sdk/analytics/test-setup.ts new file mode 100644 index 000000000000..7f185d40e06f --- /dev/null +++ b/core-web/libs/sdk/analytics/test-setup.ts @@ -0,0 +1,4 @@ +import '@testing-library/jest-dom'; +import '@testing-library/react'; + +global.React = require('react'); diff --git a/core-web/libs/sdk/analytics/tsconfig.json b/core-web/libs/sdk/analytics/tsconfig.json index 1e4d7f064943..98feb7970a52 100644 --- a/core-web/libs/sdk/analytics/tsconfig.json +++ b/core-web/libs/sdk/analytics/tsconfig.json @@ -1,14 +1,11 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "module": "commonjs", "jsx": "react-jsx", - "forceConsistentCasingInFileNames": true, - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true }, "files": [], "include": [], diff --git a/core-web/libs/sdk/analytics/tsconfig.lib.json b/core-web/libs/sdk/analytics/tsconfig.lib.json index ed793751aabe..7fcac3909d1c 100644 --- a/core-web/libs/sdk/analytics/tsconfig.lib.json +++ b/core-web/libs/sdk/analytics/tsconfig.lib.json @@ -1,16 +1,19 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "module": "ES2015", - "target": "es2015", - "declaration": true, "outDir": "../../../dist/libs/sdk/analytics", - "types": ["node", "jest"], - "moduleResolution": "node", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] }, "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ] } diff --git a/examples/nextjs/src/components/content-types/activity.js b/examples/nextjs/src/components/content-types/activity.js index 6fb78d353ab6..284821cfd725 100644 --- a/examples/nextjs/src/components/content-types/activity.js +++ b/examples/nextjs/src/components/content-types/activity.js @@ -1,8 +1,6 @@ import Image from "next/image"; import Link from "next/link"; function Activity({ title, description, image, urlTitle }) { - // const { track } = useContentAnalytics(); // TODO: Uncomment this line to use Content Analytics - return (
{image && ( @@ -20,7 +18,6 @@ function Activity({ title, description, image, urlTitle }) {
track("btn-click", { title, urlTitle })} // TODO: Uncomment this line to use Content Analytics href={`/activities/${urlTitle || "#"}`} className="inline-block px-4 py-2 font-bold text-white bg-purple-500 rounded-full hover:bg-purple-700" > diff --git a/examples/nextjs/src/components/my-page.js b/examples/nextjs/src/components/my-page.js index ded7140ec400..cd3667a80840 100644 --- a/examples/nextjs/src/components/my-page.js +++ b/examples/nextjs/src/components/my-page.js @@ -19,8 +19,6 @@ import Navigation from "./layout/navigation"; import NotFound from "@/app/not-found"; import { usePageAsset } from "../hooks/usePageAsset"; -// import { DotContentAnalyticsProvider } from "@dotcms/analytics/react"; - /** * Configure experiment settings below. If you are not using experiments, * you can ignore or remove the experiment-related code and imports. @@ -31,13 +29,6 @@ const experimentConfig = { debug: process.env.NEXT_PUBLIC_EXPERIMENTS_DEBUG, // Debug mode for additional logging }; -// Example configuration for Content Analytics -const analyticsConfig = { - apiKey: process.env.NEXT_PUBLIC_ANALYTICS_API_KEY, // API key for Content Analytics, is the same of Experiments, should be securely stored - server: process.env.NEXT_PUBLIC_DOTCMS_HOST, // DotCMS server endpoint - debug: process.env.NEXT_PUBLIC_ANALYTICS_DEBUG, // Debug mode for additional logging -}; - // Mapping of components to DotCMS content types const componentsMap = { Blog: Blog, @@ -76,7 +67,6 @@ export function MyPage({ pageAsset, nav }) { } return ( - //
{pageAsset?.layout.header && (
{!!nav && }
@@ -101,6 +91,5 @@ export function MyPage({ pageAsset, nav }) { {pageAsset?.layout.footer &&
- //
); }