Skip to content

Commit adb216f

Browse files
committed
✨(frontend) add stat from Crisp
We want to track document views with user authentication status using Crisp analytics.
1 parent 235c182 commit adb216f

File tree

6 files changed

+205
-9
lines changed

6 files changed

+205
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to
1111
- ✨(frontend) integrate configurable Waffle #1795
1212
- ✨ Import of documents #1609
1313
- 🚨(CI) gives warning if theme not updated #1811
14+
- ✨(frontend) Add stat from Crisp #1824
1415
- ✨(auth) add silent login #1690
1516
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
1617

src/frontend/apps/impress/src/core/config/ConfigProvider.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
useSynchronizedLanguage,
1313
} from '@/features/language';
1414
import { useAnalytics } from '@/libs';
15-
import { CrispProvider, PostHogAnalytic } from '@/services';
15+
import { CrispAnalytic, PostHogAnalytic } from '@/services';
1616
import { useSentryStore } from '@/stores/useSentryStore';
1717

1818
import { useConfig } from './api/useConfig';
@@ -73,6 +73,14 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
7373
new PostHogAnalytic(conf.POSTHOG_KEY);
7474
}, [conf?.POSTHOG_KEY]);
7575

76+
useEffect(() => {
77+
if (!conf?.CRISP_WEBSITE_ID) {
78+
return;
79+
}
80+
81+
new CrispAnalytic({ websiteId: conf.CRISP_WEBSITE_ID });
82+
}, [conf?.CRISP_WEBSITE_ID]);
83+
7684
if (!conf) {
7785
return (
7886
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
@@ -91,11 +99,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
9199
{conf?.FRONTEND_JS_URL && (
92100
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
93101
)}
94-
<AnalyticsProvider>
95-
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
96-
{children}
97-
</CrispProvider>
98-
</AnalyticsProvider>
102+
<AnalyticsProvider>{children}</AnalyticsProvider>
99103
</>
100104
);
101105
};
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { render } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, test, vi } from 'vitest';
4+
5+
import { AppWrapper } from '@/tests/utils';
6+
7+
import { LinkReach } from '../../doc-management';
8+
import { DocEditor } from '../components/DocEditor';
9+
10+
vi.mock('@/stores', () => ({
11+
useResponsiveStore: () => ({ isDesktop: false }),
12+
}));
13+
14+
vi.mock('@/features/skeletons', () => ({
15+
useSkeletonStore: () => ({
16+
setIsSkeletonVisible: vi.fn(),
17+
}),
18+
}));
19+
20+
vi.mock('../../doc-management', async () => {
21+
const actual = await vi.importActual<any>('../../doc-management');
22+
return {
23+
...actual,
24+
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
25+
useProviderStore: () => ({
26+
provider: {
27+
configuration: { name: 'test-doc-id' },
28+
document: {
29+
getXmlFragment: () => null,
30+
},
31+
},
32+
isReady: true,
33+
}),
34+
getDocLinkReach: (doc: any) => doc.computed_link_reach,
35+
};
36+
});
37+
38+
vi.mock('../../doc-table-content', () => ({
39+
TableContent: () => null,
40+
}));
41+
42+
vi.mock('../../doc-header', () => ({
43+
DocHeader: () => null,
44+
}));
45+
46+
vi.mock('../components/BlockNoteEditor', () => ({
47+
BlockNoteEditor: () => null,
48+
BlockNoteReader: () => null,
49+
}));
50+
51+
vi.mock('../../../auth', async () => {
52+
const actual = await vi.importActual<any>('../../../auth');
53+
return {
54+
...actual,
55+
useAuth: () => ({ authenticated: true }),
56+
};
57+
});
58+
59+
const TrackEventMock = vi.fn();
60+
vi.mock('../../../../libs', async () => {
61+
const actual = await vi.importActual<any>('../../../../libs');
62+
return {
63+
...actual,
64+
useAnalytics: () => ({
65+
trackEvent: TrackEventMock,
66+
}),
67+
};
68+
});
69+
70+
describe('DocEditor', () => {
71+
test('it checks that trackevent is called with correct parameters', () => {
72+
const doc = {
73+
id: 'test-doc-id-1',
74+
computed_link_reach: LinkReach.PUBLIC,
75+
deleted_at: null,
76+
abilities: {
77+
partial_update: true,
78+
},
79+
} as any;
80+
81+
const { rerender } = render(<DocEditor doc={doc} />, {
82+
wrapper: AppWrapper,
83+
});
84+
85+
expect(TrackEventMock).toHaveBeenCalledWith({
86+
eventName: 'doc',
87+
isPublic: true,
88+
authenticated: true,
89+
});
90+
91+
// Rerender with same doc to check that event is not tracked again
92+
rerender(
93+
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} />,
94+
);
95+
96+
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
97+
eventName: 'doc',
98+
isPublic: true,
99+
authenticated: true,
100+
});
101+
102+
// Rerender with different doc to check that event is tracked again
103+
rerender(
104+
<DocEditor
105+
doc={{
106+
...doc,
107+
id: 'test-doc-id-2',
108+
computed_link_reach: LinkReach.RESTRICTED,
109+
}}
110+
/>,
111+
);
112+
113+
expect(TrackEventMock).toHaveBeenNthCalledWith(2, {
114+
eventName: 'doc',
115+
isPublic: false,
116+
authenticated: true,
117+
});
118+
});
119+
});

src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import clsx from 'clsx';
2-
import { useEffect } from 'react';
2+
import { useEffect, useState } from 'react';
33

44
import { Box, Loading } from '@/components';
55
import { DocHeader } from '@/docs/doc-header/';
66
import {
77
Doc,
8+
LinkReach,
9+
getDocLinkReach,
810
useIsCollaborativeEditable,
911
useProviderStore,
1012
} from '@/docs/doc-management';
1113
import { TableContent } from '@/docs/doc-table-content/';
14+
import { useAuth } from '@/features/auth/';
1215
import { useSkeletonStore } from '@/features/skeletons';
16+
import { useAnalytics } from '@/libs';
1317
import { useResponsiveStore } from '@/stores';
1418

1519
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
@@ -83,13 +87,41 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
8387
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
8488
const { setIsSkeletonVisible } = useSkeletonStore();
8589
const isProviderReady = isReady && provider;
90+
const { trackEvent } = useAnalytics();
91+
const [hasTracked, setHasTracked] = useState(false);
92+
const { authenticated } = useAuth();
93+
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
8694

8795
useEffect(() => {
8896
if (isProviderReady) {
8997
setIsSkeletonVisible(false);
9098
}
9199
}, [isProviderReady, setIsSkeletonVisible]);
92100

101+
/**
102+
* Track doc view event only once per doc change
103+
*/
104+
useEffect(() => {
105+
setHasTracked(false);
106+
}, [doc.id]);
107+
108+
/**
109+
* Track doc view event
110+
*/
111+
useEffect(() => {
112+
if (hasTracked) {
113+
return;
114+
}
115+
116+
setHasTracked(true);
117+
118+
trackEvent({
119+
eventName: 'doc',
120+
isPublic: isPublicDoc,
121+
authenticated,
122+
});
123+
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
124+
93125
if (!isProviderReady || provider?.configuration.name !== doc.id) {
94126
return <Loading />;
95127
}

src/frontend/apps/impress/src/libs/Analytics.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@ type AnalyticEventUser = {
88
id: string;
99
email: string;
1010
};
11+
type AnalyticEventDoc = {
12+
eventName: 'doc';
13+
isPublic: boolean;
14+
authenticated: boolean;
15+
};
1116

12-
export type AnalyticEvent = AnalyticEventClick | AnalyticEventUser;
17+
export type AnalyticEvent =
18+
| AnalyticEventClick
19+
| AnalyticEventUser
20+
| AnalyticEventDoc;
1321

1422
export abstract class AbstractAnalytic {
1523
public constructor() {

src/frontend/apps/impress/src/services/Crisp.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
*/
44

55
import { Crisp } from 'crisp-sdk-web';
6-
import { PropsWithChildren, useEffect, useState } from 'react';
6+
import { JSX, PropsWithChildren, ReactNode, useEffect, useState } from 'react';
77
import { createGlobalStyle } from 'styled-components';
88

99
import { User } from '@/features/auth';
10+
import { AbstractAnalytic, AnalyticEvent } from '@/libs';
1011

1112
const CrispStyle = createGlobalStyle`
1213
#crisp-chatbox a{
@@ -70,3 +71,34 @@ export const CrispProvider = ({
7071
</>
7172
);
7273
};
74+
75+
export class CrispAnalytic extends AbstractAnalytic {
76+
private conf?: CrispProviderProps = undefined;
77+
private EVENT = {
78+
PUBLIC_DOC_NOT_CONNECTED: 'public-doc-not-connected',
79+
};
80+
81+
public constructor(conf?: CrispProviderProps) {
82+
super();
83+
84+
this.conf = conf;
85+
}
86+
87+
public Provider(children?: ReactNode): JSX.Element {
88+
return (
89+
<CrispProvider websiteId={this.conf?.websiteId}>{children}</CrispProvider>
90+
);
91+
}
92+
93+
public trackEvent(evt: AnalyticEvent): void {
94+
if (evt.eventName === 'doc') {
95+
if (evt.isPublic && !evt.authenticated) {
96+
Crisp.trigger.run(this.EVENT.PUBLIC_DOC_NOT_CONNECTED);
97+
}
98+
}
99+
}
100+
101+
public isFeatureFlagActivated(): boolean {
102+
return true;
103+
}
104+
}

0 commit comments

Comments
 (0)