Skip to content

Commit

Permalink
feat: add stateChange functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
olavoparno committed Apr 21, 2021
1 parent ba023d5 commit 0612234
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 130 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'react/jsx-filename-extension': ['error', { extensions: ['.js', '.tsx'] }],
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
'react/prop-types': 'off',
'react/jsx-one-expression-per-line': 'off',
'import/extensions': ['error', 'never'],
Expand All @@ -46,5 +46,6 @@ module.exports = {
],
'react/state-in-constructor': 'off',
'no-console': 'off',
'react/react-in-jsx-scope': 'off',
},
};
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { terser } from 'rollup-plugin-terser';
import pkg from './package.json';

export default {
input: 'src/index.tsx',
input: 'src/index.ts',
output: [
{
file: pkg.main,
Expand Down
110 changes: 83 additions & 27 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { renderHook } from '@testing-library/react-hooks';
import useHotjar, { useAppendHeadScript } from '..';
import useHotjar from '..';

interface IWindowHotjarEmbedded extends Window {
hj?: (method: string, ...data: unknown[]) => void;
}

declare const window: IWindowHotjarEmbedded;

const fakeHotjarFunction = jest.fn((method: string, ...data: unknown[]) => {
return null;
});

describe('Tests useHotjar', () => {
beforeAll(() => {
window.hj = fakeHotjarFunction;
});

afterEach(() => {
jest.clearAllMocks();
});
Expand Down Expand Up @@ -56,31 +70,35 @@ describe('Tests useHotjar', () => {
});
});

it('should identifyHotjar with pure object stringified', () => {
it('should stateChange with new relative path', () => {
const { result } = renderHook(() => useHotjar());
const identifyHotjarSpy = jest.spyOn(result.current, 'identifyHotjar');
const { identifyHotjar } = result.current;
const stateChangeSpy = jest.spyOn(result.current, 'stateChange');
const { stateChange } = result.current;

identifyHotjar(
'123-123-abc',
JSON.stringify({
name: 'olli',
surname: 'parno',
address: 'streets of tomorrow',
})
);
stateChange('new/relative/path');

expect(identifyHotjarSpy).toHaveBeenCalledWith(
'123-123-abc',
JSON.stringify({
name: 'olli',
surname: 'parno',
address: 'streets of tomorrow',
})
expect(stateChangeSpy).toHaveBeenCalledWith('new/relative/path');
});

it('should stateChange with new relative path with logCallback', () => {
const { result } = renderHook(() => useHotjar());
const stateChangeSpy = jest.spyOn(result.current, 'stateChange');
const consoleInfoSpy = jest.spyOn(console, 'info');
const { stateChange } = result.current;

const logCallback = console.info;

stateChange('new/relative/path', logCallback);

expect(stateChangeSpy).toHaveBeenCalledWith(
'new/relative/path',
logCallback
);
expect(consoleInfoSpy).toHaveBeenCalledWith('Hotjar stateChanged');
});

it('should identifyHotjar with broken logCallback', () => {
console.error = jest.fn();
const { result } = renderHook(() => useHotjar());
const identifyHotjarSpy = jest.spyOn(result.current, 'identifyHotjar');
const consoleErrorSpy = jest.spyOn(console, 'error');
Expand Down Expand Up @@ -126,18 +144,56 @@ describe('Tests useHotjar', () => {
{ name: 'olli', surname: 'parno', address: 'streets of tomorrow' },
logCallback
);
expect(consoleInfoSpy).toHaveBeenCalledWith('Hotjar identified: true');
expect(consoleInfoSpy).toHaveBeenCalledWith('Hotjar identified');
});
});

it('should useAppendHeadScript with wrong script', () => {
const { result } = renderHook(() => useAppendHeadScript());
const appendHeadScriptSpy = jest.spyOn(result.current, 'appendHeadScript');
describe('Tests Hotjar without being loaded into window', () => {
beforeAll(() => {
window.hj = undefined;
console.error = jest.fn();
});

const { appendHeadScript } = result.current;
it('should not init hotjar and throw errors', () => {
const { result } = renderHook(() => useHotjar());
const { initHotjar } = result.current;
const consoleErrorSpy = jest.spyOn(console, 'error');

const hasItBeenAppended = appendHeadScript('my-script', '123');
initHotjar(123, 6);

expect(appendHeadScriptSpy).toHaveBeenCalledWith('my-script', '123');
expect(hasItBeenAppended).toBeFalsy();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Hotjar error:',
Error('Hotjar initialization failed!')
);
});

it('should identifyHotjar with pure object and throw errors', () => {
const { result } = renderHook(() => useHotjar());
const { identifyHotjar } = result.current;
const consoleErrorSpy = jest.spyOn(console, 'error');

identifyHotjar('123-123-abc', {
name: 'olli',
surname: 'parno',
address: 'streets of tomorrow',
});

expect(consoleErrorSpy).toHaveBeenCalledWith(
'Hotjar error:',
Error('Hotjar is not available! Is Hotjar initialized?')
);
});

it('should stateChange with new relative path and throw errros', () => {
const { result } = renderHook(() => useHotjar());
const { stateChange } = result.current;
const consoleErrorSpy = jest.spyOn(console, 'error');

stateChange('new/relative/path');

expect(consoleErrorSpy).toHaveBeenCalledWith(
'Hotjar error:',
Error('Hotjar is not available! Is Hotjar initialized?')
);
});
});
90 changes: 90 additions & 0 deletions src/dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
interface IWindowHotjarEmbedded extends Window {
hj?: (method: string, ...data: unknown[]) => void;
}

declare const window: IWindowHotjarEmbedded;

export type TUserInfo = Record<
string | number,
string | number | Date | boolean
>;

export interface IUseHotjar {
readyState: boolean;
initHotjar: (
hotjarId: number,
hotjarVersion: number,
logCallback?: (...data: unknown[]) => void
) => boolean;
identifyHotjar: (
userId: string,
userInfo: TUserInfo,
logCallback?: (...data: unknown[]) => void
) => boolean;
stateChange: (
relativePath: string,
logCallback?: ((...data: unknown[]) => void) | undefined
) => boolean;
}

export const appendHeadScript = (
scriptText: string,
scriptId: string
): boolean => {
try {
const existentScript = document.getElementById(
scriptId
) as HTMLScriptElement;
const script = existentScript || document.createElement('script');
script.id = scriptId;
script.innerText = scriptText;
script.crossOrigin = 'anonymous';

document.head.appendChild(script);

return true;
} catch {
return false;
}
};

export function hotjarInitScript(
hotjarId: number,
hotjarVersion: number
): boolean {
const hotjarScriptCode = `(function(h,o,t,j,a,r){h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};h._hjSettings={hjid:${hotjarId},hjsv:${hotjarVersion}};a=o.getElementsByTagName('head')[0];r=o.createElement('script');r.async=1;r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;a.appendChild(r);})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');`;
const isAppended = appendHeadScript(hotjarScriptCode, 'hotjar-init-script');

if (isAppended && window && window.hj) {
return true;
}

throw Error('Hotjar initialization failed!');
}

export function hotjarStateChangeScript(relativePath: string): void {
if (window && window.hj) {
return window.hj('stateChange', relativePath);
}

throw Error('Hotjar is not available! Is Hotjar initialized?');
}

export function hotjarIdentifyScript(
userId: string | null,
userInfo: TUserInfo
): void {
if (window && window.hj) {
return window.hj('identify', userId, userInfo);
}

throw Error('Hotjar is not available! Is Hotjar initialized?');
}

export function checkReadyState(): boolean {
if (window && window.hj) {
return true;
}

return false;
}
85 changes: 85 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as React from 'react';
import {
checkReadyState,
hotjarIdentifyScript,
hotjarInitScript,
hotjarStateChangeScript,
IUseHotjar,
TUserInfo,
} from './dependencies';

export default function useHotjar(): IUseHotjar {
const isReadyState = checkReadyState();
const [readyState, setReadyState] = React.useState(
React.useMemo(() => isReadyState, [isReadyState])
);

const initHotjar = React.useCallback(
(
hotjarId: number,
hotjarVersion: number,
logCallback?: (...data: unknown[]) => void
): boolean => {
try {
hotjarInitScript(hotjarId, hotjarVersion);

setReadyState(true);

if (logCallback && typeof logCallback === 'function')
logCallback(`Hotjar ready: true`);

return true;
} catch (error) {
console.error('Hotjar error:', error);

return false;
}
},
[]
);

const identifyHotjar = React.useCallback(
(
userId: string | null,
userInfo: TUserInfo,
logCallback?: (...data: unknown[]) => void
): boolean => {
try {
hotjarIdentifyScript(userId, userInfo);

if (logCallback && typeof logCallback === 'function')
logCallback(`Hotjar identified`);

return true;
} catch (error) {
console.error('Hotjar error:', error);

return false;
}
},
[]
);

const stateChange = React.useCallback(
(relativePath: string, logCallback?: (...data: unknown[]) => void) => {
try {
hotjarStateChangeScript(relativePath);

if (logCallback && typeof logCallback === 'function')
logCallback(`Hotjar stateChanged`);

return true;
} catch (error) {
console.error('Hotjar error:', error);

return false;
}
},
[]
);

return React.useMemo(
() => ({ readyState, stateChange, initHotjar, identifyHotjar }),
[readyState, stateChange, initHotjar, identifyHotjar]
);
}
Loading

0 comments on commit 0612234

Please sign in to comment.