Skip to content

Commit

Permalink
feat(core): make event track great again (#7695)
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Aug 6, 2024
1 parent f93743d commit cc09085
Show file tree
Hide file tree
Showing 17 changed files with 496 additions and 198 deletions.
7 changes: 2 additions & 5 deletions packages/frontend/core/src/commands/affine-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { createStore } from 'jotai';

import { openSettingModalAtom, openWorkspaceListModalAtom } from '../atoms';
import type { useNavigateHelper } from '../hooks/use-navigate-helper';
import { mixpanel } from '../mixpanel';
import { mixpanel, track } from '../mixpanel';
import { registerAffineCommand } from './registry';

export function registerAffineNavigationCommands({
Expand Down Expand Up @@ -77,10 +77,7 @@ export function registerAffineNavigationCommands({
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
keyBinding: '$mod+,',
run() {
mixpanel.track('SettingsViewed', {
// page:
segment: 'cmdk',
});
track.$.cmdk.settings.openSettings();
store.set(openSettingModalAtom, s => ({
activeTab: 'appearance',
open: !s.open,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { mixpanel } from '@affine/core/mixpanel';
import type { MixpanelEvents } from '@affine/core/mixpanel/events';
import { SubscriptionPlan } from '@affine/graphql';
import { useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
Expand All @@ -25,7 +24,7 @@ export const CancelAction = ({
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
module: MixpanelEvents['PlanChangeStarted']['module'];
module: string;
} & PropsWithChildren) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setIsMutating] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useDowngradeNotify } from '@affine/core/components/affine/subscription-
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { mixpanel } from '@affine/core/mixpanel';
import type { MixpanelEvents } from '@affine/core/mixpanel/events';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
Expand All @@ -12,7 +11,7 @@ import { nanoid } from 'nanoid';
import { useState } from 'react';

export interface AICancelProps extends ButtonProps {
module: MixpanelEvents['PlanChangeStarted']['module'];
module: string;
}
export const AICancel = ({ module, ...btnProps }: AICancelProps) => {
const t = useI18n();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { mixpanel } from '@affine/core/mixpanel';
import type { MixpanelEvents } from '@affine/core/mixpanel/events';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
Expand All @@ -17,7 +16,7 @@ import { nanoid } from 'nanoid';
import { useState } from 'react';

export interface AIResumeProps extends ButtonProps {
module: MixpanelEvents['PlanChangeStarted']['module'];
module: string;
}

export const AIResume = ({ module, ...btnProps }: AIResumeProps) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@ export const RootAppSidebar = (): ReactElement => {
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
const onOpenQuickSearchModal = useCallback(() => {
cmdkQuickSearchService.toggle();
mixpanel.track('QuickSearchOpened', {
segment: 'navigation panel',
control: 'search button',
});
}, [cmdkQuickSearchService]);

const allPageActive = currentPath === '/all';
Expand Down Expand Up @@ -157,6 +153,7 @@ export const RootAppSidebar = (): ReactElement => {
<QuickSearchInput
className={quickSearch}
data-testid="slider-bar-quick-search-button"
data-event-props="$.navigationPanel.generalFunction.quickSearch"
onClick={onOpenQuickSearchModal}
/>
<AddPageButton onClick={onClickNewPage} />
Expand Down
148 changes: 148 additions & 0 deletions packages/frontend/core/src/mixpanel/__tests__/auto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* @vitest-environment happy-dom
*/
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';

import { enableAutoTrack, makeTracker } from '../auto';

describe('callable events chain', () => {
const call = vi.fn();
const track = makeTracker(call);

beforeEach(() => {
call.mockClear();
});

test('should call track with event and props', () => {
// @ts-expect-error fake chain
track.pageA.segmentA.moduleA.eventA();

expect(call).toBeCalledWith('eventA', {
page: 'pageA',
segment: 'segmentA',
module: 'moduleA',
});
});

test('should be able to override props', () => {
// @ts-expect-error fake chain
track.pageA.segmentA.moduleA.eventA({ page: 'pageB', control: 'controlA' });

expect(call).toBeCalledWith('eventA', {
page: 'pageB',
segment: 'segmentA',
module: 'moduleA',
control: 'controlA',
});
});

test('should be able to append custom props', () => {
// @ts-expect-error fake chain
track.pageA.segmentA.moduleA.eventA({ custom: 'prop' });

expect(call).toBeCalledWith('eventA', {
page: 'pageA',
segment: 'segmentA',
module: 'moduleA',
custom: 'prop',
});
});

test('should be able to ignore matrix named with placeholder `$`', () => {
// @ts-expect-error fake chain
track.$.segmentA.moduleA.eventA();
// @ts-expect-error fake chain
track.pageA.$.moduleA.eventA();
// @ts-expect-error fake chain
track.pageA.segmentA.$.eventA();
// @ts-expect-error fake chain
track.$.$.$.eventA();

const args = [
{
segment: 'segmentA',
module: 'moduleA',
},
{
page: 'pageA',
module: 'moduleA',
},
{
page: 'pageA',
segment: 'segmentA',
},
{},
];

args.forEach((arg, i) => {
expect(call).toHaveBeenNthCalledWith(i + 1, 'eventA', arg);
});
});
});

describe('auto track with dom dataset', () => {
const root = document.createElement('div');
const call = vi.fn();
beforeAll(() => {
call.mockReset();
root.innerHTML = '';
return enableAutoTrack(root, call);
});

test('should ignore if data-event-props not set', () => {
const nonTrackBtn = document.createElement('button');
root.append(nonTrackBtn);

nonTrackBtn.click();

expect(call).not.toBeCalled();
});

test('should track event with props', () => {
const btn = document.createElement('button');
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
root.append(btn);

btn.click();

expect(call).toBeCalledWith('createDoc', {
page: 'allDocs',
segment: 'header',
module: 'actions',
});
});

test('should track event with single', () => {
const btn = document.createElement('button');
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
btn.dataset.eventArg = 'test';
root.append(btn);

btn.click();

expect(call).toBeCalledWith('createDoc', {
page: 'allDocs',
segment: 'header',
module: 'actions',
arg: 'test',
});
});

test('should track event with multiple args', () => {
const btn = document.createElement('button');
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
btn.dataset.eventArgsFoo = 'bar';
btn.dataset.eventArgsBaz = 'qux';
root.append(btn);

btn.click();

expect(call).toBeCalledWith('createDoc', {
page: 'allDocs',
segment: 'header',
module: 'actions',
foo: 'bar',
baz: 'qux',
});
});
});
118 changes: 118 additions & 0 deletions packages/frontend/core/src/mixpanel/auto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { DebugLogger } from '@affine/debug';

import type { CallableEventsChain, EventsUnion } from './types';

const logger = new DebugLogger('mixpanel');

interface TrackFn {
(event: string, props: Record<string, any>): void;
}

const levels = ['page', 'segment', 'module', 'event'] as const;
export function makeTracker(trackFn: TrackFn): CallableEventsChain {
function makeTrackerInner(level: number, info: Record<string, string>) {
const proxy = new Proxy({} as Record<string, any>, {
get(target, prop) {
if (
typeof prop !== 'string' ||
prop === '$$typeof' /* webpack hot load reading this prop */
) {
return undefined;
}

if (levels[level] === 'event') {
return (arg: string | Record<string, any>) => {
trackFn(prop, {
...info,
...(typeof arg === 'string' ? { arg } : arg),
});
};
} else {
let levelProxy = target[prop];
if (levelProxy) {
return levelProxy;
}

levelProxy = makeTrackerInner(
level + 1,
prop === '$' ? { ...info } : { ...info, [levels[level]]: prop }
);
target[prop] = levelProxy;
return levelProxy;
}
},
});

return proxy;
}

return makeTrackerInner(0, {}) as CallableEventsChain;
}

/**
* listen on clicking on all subtree elements and auto track events if defined
*
* @example
*
* ```html
* <button data-event-chain='$.cmdk.settings.quicksearch.changeLanguage' data-event-arg='cn' />
* ```
*/
export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) {
const listener = (e: Event) => {
const el = e.target as HTMLElement | null;
if (!el) {
return;
}
const dataset = el.dataset;

if (dataset['eventProps']) {
const args: Record<string, any> = {};
if (dataset['eventArg'] !== undefined) {
args['arg'] = dataset['event-arg'];
} else {
for (const argName of Object.keys(dataset)) {
if (argName.startsWith('eventArgs')) {
args[argName.slice(9).toLowerCase()] = dataset[argName];
}
}
}

const props = dataset['eventProps']
.split('.')
.map(name => (name === '$' ? undefined : name));
if (props.length !== levels.length) {
logger.error('Invalid event props on element', el);
return;
}

const event = props[3];

if (!event) {
logger.error('Invalid event props on element', el);
return;
}

trackFn(event, {
page: props[0] as any,
segment: props[1],
module: props[2],
...args,
});
}
};

root.addEventListener('click', listener, {});
return () => {
root.removeEventListener('click', listener);
};
}

declare module 'react' {
// we have to declare `T` but it's actually not used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> {
'data-event-props'?: EventsUnion;
'data-event-arg'?: string;
}
}
Loading

0 comments on commit cc09085

Please sign in to comment.