Tracking interactions
Summary: Set up basic primitives for user interaction tracking. I hope the docs and unit tests in this diff are self explanatory :) In upcoming diffs: * Wire up to scuba events * Annotate all important parts of Flipper Chrome * Investigate if we can wrap important interactions of ANT by default Reviewed By: jknoxville Differential Revision: D25120234 fbshipit-source-id: 9849d565d7be27e498cc2b4db33e7d6e6938ee06
This commit is contained in:
committed by
Facebook GitHub Bot
parent
54e0d58713
commit
b885ff3b9e
@@ -10,7 +10,7 @@
|
||||
import React from 'react';
|
||||
import {Typography, Card, Table, Collapse, Button, Tabs} from 'antd';
|
||||
import {Layout, Link} from '../ui';
|
||||
import {NUX, theme} from 'flipper-plugin';
|
||||
import {NUX, theme, Tracked, TrackingScope} from 'flipper-plugin';
|
||||
import reactElementToJSXString from 'react-element-to-jsx-string';
|
||||
import {CodeOutlined} from '@ant-design/icons';
|
||||
|
||||
@@ -310,6 +310,47 @@ const demos: PreviewProps[] = [
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tracked',
|
||||
description:
|
||||
'A component that tracks component interactions. For Facebook internal builds, global stats for these interactions will be tracked. Wrap this component around another element to track its events',
|
||||
props: [
|
||||
[
|
||||
'events',
|
||||
'string | string[] (default: "onClick")',
|
||||
'The event(s) of the child component that should be tracked',
|
||||
],
|
||||
[
|
||||
'action',
|
||||
'string (optional)',
|
||||
'Describes the element the user interacted with. Will by default be derived from the title, key or contents of the element',
|
||||
],
|
||||
],
|
||||
demos: {
|
||||
'Basic example': (
|
||||
<Tracked>
|
||||
<Button onClick={() => {}}>Test</Button>
|
||||
</Tracked>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'TrackingScope',
|
||||
description:
|
||||
'Describes more precisely the place in the UI for all underlying Tracked elements. Multiple Tracking scopes are automatically nested. Use the `withTrackingScope` HoC to automatically wrap a component definition in a tracking scope',
|
||||
props: [
|
||||
['scope', 'string', 'The name of the scope. For example "Login Dialog"'],
|
||||
],
|
||||
demos: {
|
||||
'Basic example': (
|
||||
<TrackingScope scope="Tracking scope demo">
|
||||
<Tracked>
|
||||
<Button onClick={() => {}}>Test</Button>
|
||||
</Tracked>
|
||||
</TrackingScope>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function ComponentPreview({title, demos, description, props}: PreviewProps) {
|
||||
|
||||
@@ -29,14 +29,18 @@ test('Correct top level API exposed', () => {
|
||||
"Layout",
|
||||
"NUX",
|
||||
"TestUtils",
|
||||
"Tracked",
|
||||
"TrackingScope",
|
||||
"batch",
|
||||
"createState",
|
||||
"produce",
|
||||
"renderReactRoot",
|
||||
"sleep",
|
||||
"styled",
|
||||
"theme",
|
||||
"usePlugin",
|
||||
"useValue",
|
||||
"withTrackingScope",
|
||||
]
|
||||
`);
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ export {
|
||||
} from './ui/NUX';
|
||||
|
||||
export {renderReactRoot} from './utils/renderReactRoot';
|
||||
export {
|
||||
Tracked,
|
||||
TrackingScope,
|
||||
setGlobalInteractionReporter as _setGlobalInteractionReporter,
|
||||
withTrackingScope,
|
||||
} from './ui/Tracked';
|
||||
|
||||
export {sleep} from './utils/sleep';
|
||||
|
||||
// It's not ideal that this exists in flipper-plugin sources directly,
|
||||
// but is the least pain for plugin authors.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {SandyPluginContext} from './PluginContext';
|
||||
import {SandyPluginInstance} from './Plugin';
|
||||
import {SandyDevicePluginInstance} from './DevicePlugin';
|
||||
import {BasePluginInstance} from './PluginBase';
|
||||
import {TrackingScope} from '../ui/Tracked';
|
||||
|
||||
type Props = {
|
||||
plugin: SandyPluginInstance | SandyDevicePluginInstance;
|
||||
@@ -32,8 +33,10 @@ export const SandyPluginRenderer = memo(({plugin}: Props) => {
|
||||
}, [plugin]);
|
||||
|
||||
return (
|
||||
<TrackingScope scope={'plugin:' + plugin.definition.id}>
|
||||
<SandyPluginContext.Provider value={plugin}>
|
||||
{createElement(plugin.definition.module.Component)}
|
||||
</SandyPluginContext.Provider>
|
||||
</TrackingScope>
|
||||
);
|
||||
});
|
||||
|
||||
183
desktop/flipper-plugin/src/ui/Tracked.tsx
Normal file
183
desktop/flipper-plugin/src/ui/Tracked.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {Children, cloneElement, createContext, useContext} from 'react';
|
||||
import reactElementToJSXString from 'react-element-to-jsx-string';
|
||||
|
||||
export type InteractionReport = {
|
||||
// Duration of the event handler itself, not including any time the promise handler might have been pending
|
||||
duration: number;
|
||||
// Duration of the total promise chain return by the event handler.
|
||||
totalDuration: number;
|
||||
success: 1 | 0;
|
||||
error?: string;
|
||||
scope: string;
|
||||
action: string;
|
||||
componentType: string;
|
||||
event: string;
|
||||
};
|
||||
|
||||
export type InteractionReporter = (report: InteractionReport) => void;
|
||||
|
||||
let globalInteractionReporter: InteractionReporter = () => {};
|
||||
|
||||
export function setGlobalInteractionReporter(reporter: InteractionReporter) {
|
||||
globalInteractionReporter = reporter;
|
||||
}
|
||||
|
||||
// For unit tests only
|
||||
export function resetGlobalInteractionReporter() {
|
||||
globalInteractionReporter = () => {};
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE = 'Flipper';
|
||||
|
||||
const TrackingScopeContext = createContext(DEFAULT_SCOPE);
|
||||
|
||||
export function TrackingScope({
|
||||
scope,
|
||||
children,
|
||||
}: {
|
||||
scope: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const baseScope = useContext(TrackingScopeContext);
|
||||
return (
|
||||
<TrackingScopeContext.Provider
|
||||
value={baseScope === DEFAULT_SCOPE ? scope : `${baseScope}:${scope}`}>
|
||||
{children}
|
||||
</TrackingScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tracked({
|
||||
events = 'onClick',
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
/**
|
||||
* Name of the event handler properties of the child component that should be wrapped
|
||||
*/
|
||||
events?: string | string[];
|
||||
|
||||
/*
|
||||
* Name of the interaction describing what the user interacted with. If omitted, will take a description of the child
|
||||
*/
|
||||
action?: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const scope = useContext(TrackingScopeContext);
|
||||
return Children.map(children, (child: any) => {
|
||||
if (!child || typeof child !== 'object') {
|
||||
return child;
|
||||
}
|
||||
if (child.type === Tracked) return child; // avoid double trapping
|
||||
const newProps: any = {};
|
||||
(typeof events === 'string' ? [events] : events).forEach((event) => {
|
||||
const base = child.props[event];
|
||||
if (!base) {
|
||||
return;
|
||||
}
|
||||
newProps[event] = wrapInteractionHandler(
|
||||
base,
|
||||
child,
|
||||
event,
|
||||
scope,
|
||||
action,
|
||||
);
|
||||
});
|
||||
return cloneElement(child, newProps);
|
||||
}) as any;
|
||||
}
|
||||
|
||||
// Exported for test
|
||||
export function wrapInteractionHandler(
|
||||
fn: Function,
|
||||
element: React.ReactElement,
|
||||
event: string,
|
||||
scope: string,
|
||||
action?: string,
|
||||
) {
|
||||
function report(start: number, initialEnd: number, error?: any) {
|
||||
globalInteractionReporter({
|
||||
duration: initialEnd - start,
|
||||
totalDuration: Date.now() - start,
|
||||
success: error ? 0 : 1,
|
||||
error: error ? '' + error : undefined,
|
||||
componentType: describeElementType(element),
|
||||
action: action ?? describeElement(element),
|
||||
scope,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
return function trappedInteractionHandler(this: any) {
|
||||
let res: any;
|
||||
const start = Date.now();
|
||||
const r = report.bind(null, start);
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
res = fn.apply(this, arguments);
|
||||
if (Date.now() - start > 20) {
|
||||
console.warn('Slow interaction');
|
||||
}
|
||||
} catch (e) {
|
||||
r(Date.now(), e);
|
||||
throw e;
|
||||
}
|
||||
const initialEnd = Date.now();
|
||||
if (typeof res?.then === 'function') {
|
||||
// async / promise
|
||||
res.then(
|
||||
() => r(initialEnd),
|
||||
(error: any) => r(initialEnd, error),
|
||||
);
|
||||
} else {
|
||||
// not a Promise
|
||||
r(initialEnd);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
export function describeElement(element: React.ReactElement): string {
|
||||
const describing = element.props.title ?? element.props.children;
|
||||
if (typeof describing === 'string') {
|
||||
return describing;
|
||||
}
|
||||
if (typeof element.key === 'string') {
|
||||
return element.key;
|
||||
}
|
||||
return reactElementToJSXString(element).substr(0, 200).replace(/\n/g, ' ');
|
||||
}
|
||||
|
||||
function describeElementType(element: React.ReactElement): string {
|
||||
const t = element.type as any;
|
||||
// cases: native dom element, named (class) component, named function component, classname
|
||||
return typeof t === 'string'
|
||||
? t
|
||||
: t?.displayName ?? t?.name ?? t?.constructor?.name ?? 'unknown';
|
||||
}
|
||||
|
||||
export function withTrackingScope<T>(component: T): T;
|
||||
export function withTrackingScope(Component: any) {
|
||||
return function WithTrackingScope(props: any) {
|
||||
const scope =
|
||||
Component.displayName ?? Component.name ?? Component.constructor?.name;
|
||||
if (!scope) {
|
||||
throw new Error('Failed to find component name for trackingScope');
|
||||
}
|
||||
return (
|
||||
<TrackingScope scope={scope}>
|
||||
<Component {...props} />
|
||||
</TrackingScope>
|
||||
);
|
||||
};
|
||||
}
|
||||
300
desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx
Normal file
300
desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {render, fireEvent} from '@testing-library/react';
|
||||
import {TestUtils} from 'flipper-plugin';
|
||||
import {sleep} from 'flipper-plugin/src/utils/sleep';
|
||||
import React, {Component} from 'react';
|
||||
import {
|
||||
setGlobalInteractionReporter,
|
||||
resetGlobalInteractionReporter,
|
||||
InteractionReport,
|
||||
Tracked,
|
||||
wrapInteractionHandler,
|
||||
describeElement,
|
||||
TrackingScope,
|
||||
withTrackingScope,
|
||||
} from '../Tracked';
|
||||
|
||||
let events: InteractionReport[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
setGlobalInteractionReporter((e) => {
|
||||
e.duration = 0; // avoid test unstability
|
||||
e.totalDuration = 0;
|
||||
events.push(e);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalInteractionReporter();
|
||||
});
|
||||
|
||||
test('Tracked button', () => {
|
||||
const rendering = render(
|
||||
<Tracked>
|
||||
<button data-testid="test" onClick={() => {}}></button>
|
||||
</Tracked>,
|
||||
);
|
||||
|
||||
fireEvent.click(rendering.getByTestId('test'));
|
||||
expect(events[0]).toEqual({
|
||||
action: `<button data-testid=\"test\" onClick={function noRefCheck() {}} />`,
|
||||
componentType: 'button',
|
||||
duration: 0,
|
||||
totalDuration: 0,
|
||||
error: undefined,
|
||||
event: 'onClick',
|
||||
scope: 'Flipper',
|
||||
success: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('Tracked button - custom handler', () => {
|
||||
const rendering = render(
|
||||
<Tracked events={['onDoubleClick']}>
|
||||
<button data-testid="test" onDoubleClick={() => {}}></button>
|
||||
</Tracked>,
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(rendering.getByTestId('test'));
|
||||
expect(events[0]).toEqual({
|
||||
action: `<button data-testid=\"test\" onDoubleClick={function noRefCheck() {}} />`,
|
||||
componentType: 'button',
|
||||
duration: 0,
|
||||
totalDuration: 0,
|
||||
error: undefined,
|
||||
event: 'onDoubleClick',
|
||||
scope: 'Flipper',
|
||||
success: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('Throwing action', () => {
|
||||
const fn = wrapInteractionHandler(
|
||||
() => {
|
||||
throw new Error('Oops');
|
||||
},
|
||||
<button />,
|
||||
'click',
|
||||
'test',
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
fn();
|
||||
}).toThrow('Oops');
|
||||
expect(events[0]).toEqual({
|
||||
action: `<button />`,
|
||||
componentType: 'button',
|
||||
duration: 0,
|
||||
totalDuration: 0,
|
||||
error: 'Error: Oops',
|
||||
event: 'click',
|
||||
scope: 'test',
|
||||
success: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('Async action', async () => {
|
||||
const fn = wrapInteractionHandler(
|
||||
async () => {
|
||||
Promise.resolve(3);
|
||||
},
|
||||
<button />,
|
||||
'click',
|
||||
'test',
|
||||
);
|
||||
|
||||
const res = fn();
|
||||
expect(typeof fn).toBe('function');
|
||||
await res;
|
||||
expect(events[0]).toEqual({
|
||||
action: `<button />`,
|
||||
componentType: 'button',
|
||||
duration: 0,
|
||||
totalDuration: 0,
|
||||
error: undefined,
|
||||
event: 'click',
|
||||
scope: 'test',
|
||||
success: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('Throwing async action', async () => {
|
||||
const fn = wrapInteractionHandler(
|
||||
async () => {
|
||||
throw new Error('Oops');
|
||||
},
|
||||
<button />,
|
||||
'click',
|
||||
'test',
|
||||
);
|
||||
|
||||
const res = fn();
|
||||
expect(typeof fn).toBe('function');
|
||||
let error = undefined;
|
||||
try {
|
||||
await res;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect('' + error).toBe(`Error: Oops`);
|
||||
expect(events[0]).toEqual({
|
||||
action: `<button />`,
|
||||
componentType: 'button',
|
||||
duration: 0,
|
||||
totalDuration: 0,
|
||||
error: `Error: Oops`,
|
||||
event: 'click',
|
||||
scope: 'test',
|
||||
success: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('timing', async () => {
|
||||
let data: InteractionReport | undefined = undefined;
|
||||
setGlobalInteractionReporter((e) => {
|
||||
data = e;
|
||||
});
|
||||
|
||||
const fn = wrapInteractionHandler(
|
||||
async () => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 500) {
|
||||
// nothing
|
||||
}
|
||||
await sleep(1000);
|
||||
},
|
||||
<button />,
|
||||
'click',
|
||||
'test',
|
||||
);
|
||||
await fn();
|
||||
expect(data!.duration > 100).toBe(true);
|
||||
expect(data!.duration < 1000).toBe(true);
|
||||
expect(data!.totalDuration > 800).toBe(true);
|
||||
expect(data!.totalDuration < 2000).toBe(true);
|
||||
});
|
||||
|
||||
test('describeElement', () => {
|
||||
// String only child
|
||||
expect(describeElement(<button>Hi!</button>)).toBe('Hi!');
|
||||
// title
|
||||
expect(
|
||||
describeElement(
|
||||
<button key="a" title="b">
|
||||
Hi!
|
||||
</button>,
|
||||
),
|
||||
).toBe('b');
|
||||
// key + text
|
||||
expect(describeElement(<button key="a">Hi!</button>)).toBe('Hi!');
|
||||
// Rich JSX
|
||||
expect(
|
||||
describeElement(
|
||||
<button>
|
||||
<h1>Something complex</h1>Hi!
|
||||
</button>,
|
||||
),
|
||||
).toBe('<button> <h1> Something complex </h1> Hi! </button>');
|
||||
// Rich JSX with key
|
||||
expect(
|
||||
describeElement(
|
||||
<button key="test">
|
||||
<h1>Something complex</h1>Hi!
|
||||
</button>,
|
||||
),
|
||||
).toBe('test');
|
||||
});
|
||||
|
||||
test('Scoped Tracked button', () => {
|
||||
const rendering = render(
|
||||
<TrackingScope scope="outer">
|
||||
<TrackingScope scope="inner">
|
||||
<Tracked>
|
||||
<button data-testid="test" onClick={() => {}}></button>
|
||||
</Tracked>
|
||||
</TrackingScope>
|
||||
</TrackingScope>,
|
||||
);
|
||||
|
||||
fireEvent.click(rendering.getByTestId('test'));
|
||||
expect(events[0].scope).toEqual('outer:inner');
|
||||
});
|
||||
|
||||
test('Scoped Tracked button in plugin', () => {
|
||||
const res = TestUtils.renderPlugin({
|
||||
plugin() {
|
||||
return {};
|
||||
},
|
||||
Component() {
|
||||
return (
|
||||
<TrackingScope scope="outer">
|
||||
<TrackingScope scope="inner">
|
||||
<Tracked>
|
||||
<button data-testid="test" onClick={() => {}}></button>
|
||||
</Tracked>
|
||||
</TrackingScope>
|
||||
</TrackingScope>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(res.renderer.getByTestId('test'));
|
||||
expect(events[0].scope).toEqual('plugin:TestPlugin:outer:inner');
|
||||
});
|
||||
|
||||
test('withScope - fn', () => {
|
||||
const MyCoolComponent = withTrackingScope(function MyCoolComponent() {
|
||||
return (
|
||||
<Tracked>
|
||||
<button data-testid="test" onClick={() => {}}></button>
|
||||
</Tracked>
|
||||
);
|
||||
});
|
||||
|
||||
const res = TestUtils.renderPlugin({
|
||||
plugin() {
|
||||
return {};
|
||||
},
|
||||
Component() {
|
||||
return <MyCoolComponent />;
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(res.renderer.getByTestId('test'));
|
||||
expect(events[0].scope).toEqual('plugin:TestPlugin:MyCoolComponent');
|
||||
});
|
||||
|
||||
test('withScope - class', () => {
|
||||
const MyCoolComponent = withTrackingScope(
|
||||
class MyCoolComponent extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Tracked>
|
||||
<button data-testid="test" onClick={() => {}}></button>
|
||||
</Tracked>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const res = TestUtils.renderPlugin({
|
||||
plugin() {
|
||||
return {};
|
||||
},
|
||||
Component() {
|
||||
return <MyCoolComponent />;
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(res.renderer.getByTestId('test'));
|
||||
expect(events[0].scope).toEqual('plugin:TestPlugin:MyCoolComponent');
|
||||
});
|
||||
14
desktop/flipper-plugin/src/utils/sleep.tsx
Normal file
14
desktop/flipper-plugin/src/utils/sleep.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
@@ -365,6 +365,20 @@ See `View > Flipper Style Guide` inside the Flipper application for more details
|
||||
An element that can be used to provide a New User eXperience: Hints that give a one time introduction to new features to the current user.
|
||||
See `View > Flipper Style Guide` inside the Flipper application for more details.
|
||||
|
||||
### Tracked
|
||||
|
||||
An element that can be used to track user interactions.
|
||||
See `View > Flipper Style Guide` inside the Flipper application for more details.
|
||||
|
||||
### TrackingScope
|
||||
|
||||
Defines the location of underlying Tracked elements more precisely.
|
||||
See `View > Flipper Style Guide` inside the Flipper application for more details.
|
||||
|
||||
### withTrackingScope
|
||||
|
||||
Higher order component that wraps a component automatically in a [`TrackingScope`](#TrackingScope) using the component name as `scope`.
|
||||
|
||||
### theme object
|
||||
|
||||
Provides a standard set of colors and spacings, used by the Flipper style guide.
|
||||
|
||||
Reference in New Issue
Block a user