✨(frontend) add stat from Crisp
We want to track document views with user authentication status using Crisp analytics.
This commit is contained in:
@@ -11,6 +11,7 @@ and this project adheres to
|
||||
- ✨(frontend) integrate configurable Waffle #1795
|
||||
- ✨ Import of documents #1609
|
||||
- 🚨(CI) gives warning if theme not updated #1811
|
||||
- ✨(frontend) Add stat from Crisp #1824
|
||||
- ✨(auth) add silent login #1690
|
||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useSynchronizedLanguage,
|
||||
} from '@/features/language';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { CrispProvider, PostHogAnalytic } from '@/services';
|
||||
import { CrispAnalytic, PostHogAnalytic } from '@/services';
|
||||
import { useSentryStore } from '@/stores/useSentryStore';
|
||||
|
||||
import { useConfig } from './api/useConfig';
|
||||
@@ -73,6 +73,14 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
new PostHogAnalytic(conf.POSTHOG_KEY);
|
||||
}, [conf?.POSTHOG_KEY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.CRISP_WEBSITE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
new CrispAnalytic({ websiteId: conf.CRISP_WEBSITE_ID });
|
||||
}, [conf?.CRISP_WEBSITE_ID]);
|
||||
|
||||
if (!conf) {
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
@@ -91,11 +99,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
{conf?.FRONTEND_JS_URL && (
|
||||
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
|
||||
)}
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</AnalyticsProvider>
|
||||
<AnalyticsProvider>{children}</AnalyticsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { LinkReach } from '../../doc-management';
|
||||
import { DocEditor } from '../components/DocEditor';
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useResponsiveStore: () => ({ isDesktop: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/skeletons', () => ({
|
||||
useSkeletonStore: () => ({
|
||||
setIsSkeletonVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../doc-management', async () => {
|
||||
const actual = await vi.importActual<any>('../../doc-management');
|
||||
return {
|
||||
...actual,
|
||||
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
|
||||
useProviderStore: () => ({
|
||||
provider: {
|
||||
configuration: { name: 'test-doc-id' },
|
||||
document: {
|
||||
getXmlFragment: () => null,
|
||||
},
|
||||
},
|
||||
isReady: true,
|
||||
}),
|
||||
getDocLinkReach: (doc: any) => doc.computed_link_reach,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../doc-table-content', () => ({
|
||||
TableContent: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../doc-header', () => ({
|
||||
DocHeader: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BlockNoteEditor', () => ({
|
||||
BlockNoteEditor: () => null,
|
||||
BlockNoteReader: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../../auth', async () => {
|
||||
const actual = await vi.importActual<any>('../../../auth');
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => ({ authenticated: true }),
|
||||
};
|
||||
});
|
||||
|
||||
const TrackEventMock = vi.fn();
|
||||
vi.mock('../../../../libs', async () => {
|
||||
const actual = await vi.importActual<any>('../../../../libs');
|
||||
return {
|
||||
...actual,
|
||||
useAnalytics: () => ({
|
||||
trackEvent: TrackEventMock,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('DocEditor', () => {
|
||||
test('it checks that trackevent is called with correct parameters', () => {
|
||||
const doc = {
|
||||
id: 'test-doc-id-1',
|
||||
computed_link_reach: LinkReach.PUBLIC,
|
||||
deleted_at: null,
|
||||
abilities: {
|
||||
partial_update: true,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { rerender } = render(<DocEditor doc={doc} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(TrackEventMock).toHaveBeenCalledWith({
|
||||
eventName: 'doc',
|
||||
isPublic: true,
|
||||
authenticated: true,
|
||||
});
|
||||
|
||||
// Rerender with same doc to check that event is not tracked again
|
||||
rerender(
|
||||
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} />,
|
||||
);
|
||||
|
||||
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
|
||||
eventName: 'doc',
|
||||
isPublic: true,
|
||||
authenticated: true,
|
||||
});
|
||||
|
||||
// Rerender with different doc to check that event is tracked again
|
||||
rerender(
|
||||
<DocEditor
|
||||
doc={{
|
||||
...doc,
|
||||
id: 'test-doc-id-2',
|
||||
computed_link_reach: LinkReach.RESTRICTED,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(TrackEventMock).toHaveBeenNthCalledWith(2, {
|
||||
eventName: 'doc',
|
||||
isPublic: false,
|
||||
authenticated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Loading } from '@/components';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
LinkReach,
|
||||
getDocLinkReach,
|
||||
useIsCollaborativeEditable,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { useAuth } from '@/features/auth/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
|
||||
@@ -83,6 +87,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [hasTracked, setHasTracked] = useState(false);
|
||||
const { authenticated } = useAuth();
|
||||
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
|
||||
|
||||
useEffect(() => {
|
||||
if (isProviderReady) {
|
||||
@@ -90,6 +98,30 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
/**
|
||||
* Track doc view event only once per doc change
|
||||
*/
|
||||
useEffect(() => {
|
||||
setHasTracked(false);
|
||||
}, [doc.id]);
|
||||
|
||||
/**
|
||||
* Track doc view event
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (hasTracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasTracked(true);
|
||||
|
||||
trackEvent({
|
||||
eventName: 'doc',
|
||||
isPublic: isPublicDoc,
|
||||
authenticated,
|
||||
});
|
||||
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
|
||||
|
||||
if (!isProviderReady || provider?.configuration.name !== doc.id) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,16 @@ type AnalyticEventUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
type AnalyticEventDoc = {
|
||||
eventName: 'doc';
|
||||
isPublic: boolean;
|
||||
authenticated: boolean;
|
||||
};
|
||||
|
||||
export type AnalyticEvent = AnalyticEventClick | AnalyticEventUser;
|
||||
export type AnalyticEvent =
|
||||
| AnalyticEventClick
|
||||
| AnalyticEventUser
|
||||
| AnalyticEventDoc;
|
||||
|
||||
export abstract class AbstractAnalytic {
|
||||
public constructor() {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
|
||||
import { Crisp } from 'crisp-sdk-web';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { JSX, PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { User } from '@/features/auth';
|
||||
import { AbstractAnalytic, AnalyticEvent } from '@/libs';
|
||||
|
||||
const CrispStyle = createGlobalStyle`
|
||||
#crisp-chatbox a{
|
||||
@@ -70,3 +71,34 @@ export const CrispProvider = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export class CrispAnalytic extends AbstractAnalytic {
|
||||
private conf?: CrispProviderProps = undefined;
|
||||
private EVENT = {
|
||||
PUBLIC_DOC_NOT_CONNECTED: 'public-doc-not-connected',
|
||||
};
|
||||
|
||||
public constructor(conf?: CrispProviderProps) {
|
||||
super();
|
||||
|
||||
this.conf = conf;
|
||||
}
|
||||
|
||||
public Provider(children?: ReactNode): JSX.Element {
|
||||
return (
|
||||
<CrispProvider websiteId={this.conf?.websiteId}>{children}</CrispProvider>
|
||||
);
|
||||
}
|
||||
|
||||
public trackEvent(evt: AnalyticEvent): void {
|
||||
if (evt.eventName === 'doc') {
|
||||
if (evt.isPublic && !evt.authenticated) {
|
||||
Crisp.trigger.run(this.EVENT.PUBLIC_DOC_NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public isFeatureFlagActivated(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user