mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
refactor(core): adjust core struct (#8218)
packages/frontend/core/src hooks -> components/hooks atoms -> components/atoms layouts -> components/layouts providers -> components/providers mixpanel -> @affine/track ~~shared~~ ~~unexpected-application-state~~
This commit is contained in:
148
packages/frontend/track/src/__tests__/auto.spec.ts
Normal file
148
packages/frontend/track/src/__tests__/auto.spec.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
124
packages/frontend/track/src/auto.ts
Normal file
124
packages/frontend/track/src/auto.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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-reload reads 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.changeLanguage'
|
||||
* data-event-arg='cn'
|
||||
* <!-- or -->
|
||||
* data-event-args-foo='bar'
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
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;
|
||||
'data-event-args-control'?: string;
|
||||
}
|
||||
}
|
||||
406
packages/frontend/track/src/events.ts
Normal file
406
packages/frontend/track/src/events.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// let '$' stands for unspecific matrix
|
||||
/* eslint-disable rxjs/finnish */
|
||||
|
||||
// SECTION: app events
|
||||
type GeneralEvents = 'openMigrationDataHelp';
|
||||
type CmdkEvents = 'quickSearch' | 'recentDocs' | 'searchResultsDocs';
|
||||
type AppEvents =
|
||||
| 'checkUpdates'
|
||||
| 'downloadUpdate'
|
||||
| 'downloadApp'
|
||||
| 'quitAndInstall'
|
||||
| 'openChangelog'
|
||||
| 'dismissChangelog'
|
||||
| 'contactUs'
|
||||
| 'findInPage';
|
||||
type NavigationEvents =
|
||||
| 'openInNewTab'
|
||||
| 'openInSplitView'
|
||||
| 'switchTab'
|
||||
| 'switchSplitView'
|
||||
| 'tabAction'
|
||||
| 'navigate'
|
||||
| 'goBack'
|
||||
| 'goForward'
|
||||
| 'toggle' // toggle navigation panel
|
||||
| 'open'
|
||||
| 'close'; // openclose modal/diaglog
|
||||
|
||||
// END SECTION
|
||||
|
||||
// SECTION: doc events
|
||||
type WorkspaceEvents =
|
||||
| 'createWorkspace'
|
||||
| 'upgradeWorkspace'
|
||||
| 'enableCloudWorkspace'
|
||||
| 'import'
|
||||
| 'export'
|
||||
| 'openWorkspaceList';
|
||||
type DocEvents =
|
||||
| 'createDoc'
|
||||
| 'renameDoc'
|
||||
| 'linkDoc'
|
||||
| 'deleteDoc'
|
||||
| 'restoreDoc'
|
||||
| 'switchPageMode'
|
||||
| 'openDocOptionsMenu'
|
||||
| 'openDocInfo'
|
||||
| 'copyBlockToLink';
|
||||
type EditorEvents = 'bold' | 'italic' | 'underline' | 'strikeThrough';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: setting events
|
||||
type SettingEvents =
|
||||
| 'openSettings'
|
||||
| 'changeAppSetting'
|
||||
| 'changeEditorSetting';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: organize events
|
||||
type CollectionEvents =
|
||||
| 'createCollection'
|
||||
| 'deleteCollection'
|
||||
| 'renameCollection'
|
||||
| 'addDocToCollection';
|
||||
type FolderEvents =
|
||||
| 'createFolder'
|
||||
| 'renameFolder'
|
||||
| 'moveFolder'
|
||||
| 'deleteFolder';
|
||||
type TagEvents = 'createTag' | 'deleteTag' | 'renameTag' | 'tagDoc';
|
||||
type FavoriteEvents = 'toggleFavorite';
|
||||
type OrganizeItemEvents = // doc, link, folder, collection, tag
|
||||
|
||||
| 'createOrganizeItem'
|
||||
| 'renameOrganizeItem'
|
||||
| 'moveOrganizeItem'
|
||||
| 'deleteOrganizeItem'
|
||||
| 'orderOrganizeItem'
|
||||
| 'removeOrganizeItem';
|
||||
type OrganizeEvents =
|
||||
| OrganizeItemEvents
|
||||
| CollectionEvents
|
||||
| FolderEvents
|
||||
| TagEvents
|
||||
| FavoriteEvents;
|
||||
// END SECTION
|
||||
|
||||
// SECTION: cloud events
|
||||
type ShareEvents =
|
||||
| 'createShareLink'
|
||||
| 'copyShareLink'
|
||||
| 'openShareMenu'
|
||||
| 'share';
|
||||
type AuthEvents = 'signIn' | 'signInFail' | 'signedIn' | 'signOut';
|
||||
type AccountEvents = 'uploadAvatar' | 'removeAvatar' | 'updateUserName';
|
||||
type PaymentEvents =
|
||||
| 'viewPlans'
|
||||
| 'bookDemo'
|
||||
| 'checkout'
|
||||
| 'subscribe'
|
||||
| 'changeSubscriptionRecurring'
|
||||
| 'confirmChangingSubscriptionRecurring'
|
||||
| 'cancelSubscription'
|
||||
| 'confirmCancelingSubscription'
|
||||
| 'resumeSubscription'
|
||||
| 'confirmResumingSubscription';
|
||||
// END SECTION
|
||||
|
||||
type UserEvents =
|
||||
| GeneralEvents
|
||||
| AppEvents
|
||||
| NavigationEvents
|
||||
| WorkspaceEvents
|
||||
| DocEvents
|
||||
| EditorEvents
|
||||
| SettingEvents
|
||||
| CmdkEvents
|
||||
| OrganizeEvents
|
||||
| ShareEvents
|
||||
| AuthEvents
|
||||
| AccountEvents
|
||||
| PaymentEvents;
|
||||
|
||||
interface PageDivision {
|
||||
[page: string]: {
|
||||
[segment: string]: {
|
||||
[module: string]: UserEvents[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const PageEvents = {
|
||||
// page: {
|
||||
// $: {}
|
||||
// ^ if empty
|
||||
// segment: {
|
||||
// module: ['event1', 'event2']
|
||||
// },
|
||||
// },
|
||||
// to: page.$.segment.module.event1()
|
||||
$: {
|
||||
$: {
|
||||
$: ['createWorkspace', 'checkout'],
|
||||
auth: ['signIn', 'signedIn', 'signInFail', 'signOut'],
|
||||
},
|
||||
sharePanel: {
|
||||
$: ['createShareLink', 'copyShareLink', 'export', 'open'],
|
||||
},
|
||||
docInfoPanel: {
|
||||
$: ['open'],
|
||||
},
|
||||
settingsPanel: {
|
||||
menu: ['openSettings'],
|
||||
workspace: ['viewPlans'],
|
||||
profileAndBadge: ['viewPlans'],
|
||||
accountUsage: ['viewPlans'],
|
||||
accountSettings: ['uploadAvatar', 'removeAvatar', 'updateUserName'],
|
||||
plans: [
|
||||
'checkout',
|
||||
'subscribe',
|
||||
'changeSubscriptionRecurring',
|
||||
'confirmChangingSubscriptionRecurring',
|
||||
'cancelSubscription',
|
||||
'confirmCancelingSubscription',
|
||||
'resumeSubscription',
|
||||
'confirmResumingSubscription',
|
||||
],
|
||||
billing: ['viewPlans', 'bookDemo'],
|
||||
about: ['checkUpdates', 'downloadUpdate', 'changeAppSetting'],
|
||||
},
|
||||
cmdk: {
|
||||
recent: ['recentDocs'],
|
||||
results: ['searchResultsDocs'],
|
||||
general: ['copyShareLink', 'goBack', 'goForward', 'findInPage'],
|
||||
creation: ['createDoc'],
|
||||
workspace: ['createWorkspace'],
|
||||
settings: ['openSettings', 'changeAppSetting'],
|
||||
navigation: ['navigate'],
|
||||
editor: [
|
||||
'toggleFavorite',
|
||||
'switchPageMode',
|
||||
'createDoc',
|
||||
'export',
|
||||
'deleteDoc',
|
||||
'restoreDoc',
|
||||
],
|
||||
docInfo: ['open'],
|
||||
docHistory: ['open'],
|
||||
updates: ['quitAndInstall'],
|
||||
help: ['contactUs', 'openChangelog'],
|
||||
},
|
||||
navigationPanel: {
|
||||
$: ['quickSearch', 'createDoc', 'navigate', 'openSettings', 'toggle'],
|
||||
organize: [
|
||||
'createOrganizeItem',
|
||||
'renameOrganizeItem',
|
||||
'moveOrganizeItem',
|
||||
'deleteOrganizeItem',
|
||||
'orderOrganizeItem',
|
||||
'openInNewTab',
|
||||
'openInSplitView',
|
||||
'toggleFavorite',
|
||||
],
|
||||
docs: ['createDoc', 'deleteDoc', 'linkDoc'],
|
||||
collections: ['createDoc', 'addDocToCollection', 'removeOrganizeItem'],
|
||||
folders: ['createDoc'],
|
||||
tags: ['createDoc', 'tagDoc'],
|
||||
favorites: ['createDoc'],
|
||||
migrationData: ['openMigrationDataHelp'],
|
||||
bottomButtons: [
|
||||
'downloadApp',
|
||||
'quitAndInstall',
|
||||
'openChangelog',
|
||||
'dismissChangelog',
|
||||
],
|
||||
others: ['navigate', 'import'],
|
||||
workspaceList: [
|
||||
'open',
|
||||
'signIn',
|
||||
'createWorkspace',
|
||||
'createDoc',
|
||||
'openSettings',
|
||||
],
|
||||
profileAndBadge: ['openSettings'],
|
||||
journal: ['navigate'],
|
||||
},
|
||||
aiOnboarding: {
|
||||
dialog: ['viewPlans'],
|
||||
},
|
||||
docHistory: {
|
||||
$: ['open', 'close', 'switchPageMode', 'viewPlans'],
|
||||
},
|
||||
paywall: {
|
||||
storage: ['viewPlans'],
|
||||
aiAction: ['viewPlans'],
|
||||
},
|
||||
appTabsHeader: {
|
||||
$: ['tabAction'],
|
||||
},
|
||||
header: {
|
||||
actions: [
|
||||
'createDoc',
|
||||
'createWorkspace',
|
||||
'switchPageMode',
|
||||
'toggleFavorite',
|
||||
'openDocInfo',
|
||||
'renameDoc',
|
||||
],
|
||||
docOptions: [
|
||||
'open',
|
||||
'deleteDoc',
|
||||
'renameDoc',
|
||||
'switchPageMode',
|
||||
'createDoc',
|
||||
'import',
|
||||
'toggleFavorite',
|
||||
'export',
|
||||
],
|
||||
history: ['open'],
|
||||
pageInfo: ['open'],
|
||||
},
|
||||
},
|
||||
doc: {
|
||||
editor: {
|
||||
slashMenu: ['linkDoc', 'createDoc'],
|
||||
atMenu: ['linkDoc'],
|
||||
quickSearch: ['createDoc'],
|
||||
formatToolbar: ['bold'],
|
||||
pageRef: ['navigate'],
|
||||
toolbar: ['copyBlockToLink'],
|
||||
},
|
||||
inlineDocInfo: {
|
||||
$: ['toggle'],
|
||||
},
|
||||
},
|
||||
// remove when type added
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
edgeless: {},
|
||||
workspace: {
|
||||
$: {
|
||||
$: ['upgradeWorkspace'],
|
||||
},
|
||||
},
|
||||
allDocs: {
|
||||
header: {
|
||||
actions: ['createDoc', 'createWorkspace'],
|
||||
},
|
||||
list: {
|
||||
docMenu: [
|
||||
'createDoc',
|
||||
'deleteDoc',
|
||||
'openInSplitView',
|
||||
'toggleFavorite',
|
||||
'openInNewTab',
|
||||
],
|
||||
},
|
||||
},
|
||||
// remove when type added
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
collection: {
|
||||
docList: {
|
||||
docMenu: ['removeOrganizeItem'],
|
||||
},
|
||||
},
|
||||
// remove when type added
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
tag: {},
|
||||
// remove when type added
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
trash: {},
|
||||
subscriptionLanding: {
|
||||
$: {
|
||||
$: ['checkout'],
|
||||
},
|
||||
},
|
||||
} as const satisfies PageDivision;
|
||||
|
||||
type OrganizeItemType = 'doc' | 'folder' | 'collection' | 'tag' | 'favorite';
|
||||
type OrganizeItemArgs =
|
||||
| {
|
||||
type: 'link';
|
||||
target: OrganizeItemType;
|
||||
}
|
||||
| {
|
||||
type: OrganizeItemType;
|
||||
};
|
||||
|
||||
type PaymentEventArgs = {
|
||||
plan: string;
|
||||
recurring: string;
|
||||
};
|
||||
|
||||
type TabActionControlType =
|
||||
| 'click'
|
||||
| 'dnd'
|
||||
| 'midClick'
|
||||
| 'xButton'
|
||||
| 'contextMenu';
|
||||
type TabActionType =
|
||||
| 'pin'
|
||||
| 'unpin'
|
||||
| 'close'
|
||||
| 'refresh'
|
||||
| 'moveTab'
|
||||
| 'openInSplitView'
|
||||
| 'openInNewTab'
|
||||
| 'switchSplitView'
|
||||
| 'switchTab'
|
||||
| 'separateTabs';
|
||||
|
||||
type AuthArgs = {
|
||||
method: 'password' | 'magic-link' | 'oauth';
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export type EventArgs = {
|
||||
createWorkspace: { flavour: string };
|
||||
signIn: AuthArgs;
|
||||
signedIn: AuthArgs;
|
||||
signInFail: AuthArgs;
|
||||
viewPlans: PaymentEventArgs;
|
||||
checkout: PaymentEventArgs;
|
||||
subscribe: PaymentEventArgs;
|
||||
cancelSubscription: PaymentEventArgs;
|
||||
confirmCancelingSubscription: PaymentEventArgs;
|
||||
resumeSubscription: PaymentEventArgs;
|
||||
confirmResumingSubscription: PaymentEventArgs;
|
||||
changeSubscriptionRecurring: PaymentEventArgs;
|
||||
confirmChangingSubscriptionRecurring: PaymentEventArgs;
|
||||
navigate: { to: string };
|
||||
openSettings: { to: string };
|
||||
changeAppSetting: { key: string; value: string | boolean | number };
|
||||
changeEditorSetting: { key: string; value: string | boolean | number };
|
||||
createOrganizeItem: OrganizeItemArgs;
|
||||
renameOrganizeItem: OrganizeItemArgs;
|
||||
moveOrganizeItem: OrganizeItemArgs;
|
||||
removeOrganizeItem: OrganizeItemArgs;
|
||||
deleteOrganizeItem: OrganizeItemArgs;
|
||||
orderOrganizeItem: OrganizeItemArgs;
|
||||
openInNewTab: { type: OrganizeItemType };
|
||||
openInSplitView: { type: OrganizeItemType };
|
||||
tabAction: {
|
||||
type?: OrganizeItemType;
|
||||
control: TabActionControlType;
|
||||
action: TabActionType;
|
||||
};
|
||||
toggleFavorite: OrganizeItemArgs & { on: boolean };
|
||||
createDoc: { mode?: 'edgeless' | 'page' };
|
||||
switchPageMode: { mode: 'edgeless' | 'page' };
|
||||
createShareLink: { mode: 'edgeless' | 'page' };
|
||||
copyShareLink: {
|
||||
type: 'default' | 'doc' | 'whiteboard' | 'block' | 'element';
|
||||
};
|
||||
export: { type: string };
|
||||
copyBlockToLink: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
// for type checking
|
||||
// if it complains, check the definition of [EventArgs] to make sure it's key is a subset of [UserEvents]
|
||||
export const YOU_MUST_DEFINE_ARGS_WITH_WRONG_EVENT_NAME: keyof EventArgs extends UserEvents
|
||||
? true
|
||||
: false = true;
|
||||
|
||||
export type Events = typeof PageEvents;
|
||||
9
packages/frontend/track/src/index.ts
Normal file
9
packages/frontend/track/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { enableAutoTrack, makeTracker } from './auto';
|
||||
import { mixpanel } from './mixpanel';
|
||||
|
||||
export const track = makeTracker((event, props) => {
|
||||
mixpanel.track(event, props);
|
||||
});
|
||||
|
||||
export { enableAutoTrack, mixpanel };
|
||||
export default track;
|
||||
104
packages/frontend/track/src/mixpanel.ts
Normal file
104
packages/frontend/track/src/mixpanel.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser';
|
||||
import mixpanelBrowser from 'mixpanel-browser';
|
||||
|
||||
const logger = new DebugLogger('mixpanel');
|
||||
|
||||
type Middleware = (
|
||||
name: string,
|
||||
properties?: Record<string, unknown>
|
||||
) => Record<string, unknown>;
|
||||
|
||||
function createMixpanel() {
|
||||
let mixpanel;
|
||||
if (process.env.MIXPANEL_TOKEN) {
|
||||
mixpanelBrowser.init(process.env.MIXPANEL_TOKEN || '', {
|
||||
track_pageview: true,
|
||||
persistence: 'localStorage',
|
||||
api_host: 'https://telemetry.affine.run',
|
||||
ignore_dnt: true,
|
||||
});
|
||||
mixpanel = mixpanelBrowser;
|
||||
} else {
|
||||
mixpanel = new Proxy(
|
||||
function () {} as unknown as OverridedMixpanel,
|
||||
createProxyHandler()
|
||||
);
|
||||
}
|
||||
|
||||
const middlewares = new Set<Middleware>();
|
||||
|
||||
const wrapped = {
|
||||
init() {
|
||||
mixpanel.register({
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
isSelfHosted: BUILD_CONFIG.isSelfHosted,
|
||||
isDesktop: BUILD_CONFIG.isElectron,
|
||||
});
|
||||
},
|
||||
reset() {
|
||||
mixpanel.reset();
|
||||
this.init();
|
||||
},
|
||||
track(event_name: string, properties?: Record<string, any>) {
|
||||
const middlewareProperties = Array.from(middlewares).reduce(
|
||||
(acc, middleware) => {
|
||||
return middleware(event_name, acc);
|
||||
},
|
||||
properties as Record<string, unknown>
|
||||
);
|
||||
logger.debug('track', event_name, middlewareProperties);
|
||||
|
||||
mixpanel.track(event_name as string, middlewareProperties);
|
||||
},
|
||||
middleware(cb: Middleware): () => void {
|
||||
middlewares.add(cb);
|
||||
return () => {
|
||||
middlewares.delete(cb);
|
||||
};
|
||||
},
|
||||
opt_out_tracking() {
|
||||
mixpanel.opt_out_tracking();
|
||||
},
|
||||
opt_in_tracking() {
|
||||
mixpanel.opt_in_tracking();
|
||||
},
|
||||
has_opted_in_tracking() {
|
||||
mixpanel.has_opted_in_tracking();
|
||||
},
|
||||
has_opted_out_tracking() {
|
||||
mixpanel.has_opted_out_tracking();
|
||||
},
|
||||
identify(unique_id?: string) {
|
||||
mixpanel.identify(unique_id);
|
||||
},
|
||||
get people() {
|
||||
return mixpanel.people;
|
||||
},
|
||||
track_pageview(properties?: { location?: string }) {
|
||||
logger.debug('track_pageview', properties);
|
||||
mixpanel.track_pageview(properties);
|
||||
},
|
||||
};
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export const mixpanel = createMixpanel();
|
||||
mixpanel.init();
|
||||
|
||||
function createProxyHandler() {
|
||||
const handler = {
|
||||
get: () => {
|
||||
return new Proxy(
|
||||
function () {} as unknown as OverridedMixpanel,
|
||||
createProxyHandler()
|
||||
);
|
||||
},
|
||||
apply: () => {},
|
||||
} as ProxyHandler<OverridedMixpanel>;
|
||||
return handler;
|
||||
}
|
||||
70
packages/frontend/track/src/types.ts
Normal file
70
packages/frontend/track/src/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { EventArgs, Events } from './events';
|
||||
|
||||
type EventPropsOverride = {
|
||||
page?: keyof Events;
|
||||
segment?: string;
|
||||
module?: string;
|
||||
control?: string;
|
||||
};
|
||||
|
||||
export type CallableEventsChain = {
|
||||
[Page in keyof Events]: {
|
||||
[Segment in keyof Events[Page]]: {
|
||||
[Module in keyof Events[Page][Segment]]: {
|
||||
// @ts-expect-error ignore `symbol | number` as key
|
||||
[Event in Events[Page][Segment][Module][number]]: Event extends keyof EventArgs
|
||||
? (
|
||||
// we make all args partial to simply satisfies nullish type checking
|
||||
args?: Partial<EventArgs[Event]> & EventPropsOverride
|
||||
) => void
|
||||
: (args?: EventPropsOverride) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type EventsUnion = {
|
||||
[Page in keyof Events]: {
|
||||
[Segment in keyof Events[Page]]: {
|
||||
[Module in keyof Events[Page][Segment]]: {
|
||||
// @ts-expect-error ignore `symbol | number` as key
|
||||
[Event in Events[Page][Segment][Module][number]]: `${Page}.${Segment}.${Module}.${Event}`;
|
||||
// @ts-expect-error ignore `symbol | number` as key
|
||||
}[Events[Page][Segment][Module][number]];
|
||||
}[keyof Events[Page][Segment]];
|
||||
}[keyof Events[Page]];
|
||||
}[keyof Events];
|
||||
|
||||
// page > segment > module > [events]
|
||||
type IsFourLevelsDeep<
|
||||
T,
|
||||
Depth extends number[] = [],
|
||||
> = Depth['length'] extends 3
|
||||
? T extends Array<any>
|
||||
? true
|
||||
: false
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof T]: IsFourLevelsDeep<T[K], [...Depth, 0]>;
|
||||
}[keyof T] extends true
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
|
||||
// for type checking
|
||||
export const _assertIsAllEventsDefinedInFourLevels: IsFourLevelsDeep<Events> =
|
||||
true;
|
||||
|
||||
export interface EventProps {
|
||||
// location
|
||||
page?: keyof Events;
|
||||
segment?: string;
|
||||
module?: string;
|
||||
control?: string;
|
||||
arg?: string;
|
||||
|
||||
// entity
|
||||
type?: string;
|
||||
category?: string;
|
||||
id?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user