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:
EYHN
2024-09-13 11:31:21 +00:00
parent fc7e7a37ee
commit 5e56ec65e3
274 changed files with 552 additions and 902 deletions

View 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',
});
});
});

View 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;
}
}

View 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;

View 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;

View 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;
}

View 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;
}