diff --git a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx index 64952df96..379512d28 100644 --- a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx +++ b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx @@ -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': ( + + + + ), + }, + }, + { + 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': ( + + + + + + ), + }, + }, ]; function ComponentPreview({title, demos, description, props}: PreviewProps) { diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index eb91341d7..8f09482e9 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -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", ] `); diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index a340eb3ae..6ae754b33 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -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. diff --git a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx index 2f279f1da..a89040b14 100644 --- a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx @@ -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 ( - - {createElement(plugin.definition.module.Component)} - + + + {createElement(plugin.definition.module.Component)} + + ); }); diff --git a/desktop/flipper-plugin/src/ui/Tracked.tsx b/desktop/flipper-plugin/src/ui/Tracked.tsx new file mode 100644 index 000000000..d2fd4918c --- /dev/null +++ b/desktop/flipper-plugin/src/ui/Tracked.tsx @@ -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 ( + + {children} + + ); +} + +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(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 ( + + + + ); + }; +} diff --git a/desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx new file mode 100644 index 000000000..10e2153d7 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx @@ -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( + + + , + ); + + fireEvent.click(rendering.getByTestId('test')); + expect(events[0]).toEqual({ + action: ` + , + ); + + fireEvent.doubleClick(rendering.getByTestId('test')); + expect(events[0]).toEqual({ + action: `)).toBe('Hi!'); + // title + expect( + describeElement( + , + ), + ).toBe('b'); + // key + text + expect(describeElement()).toBe('Hi!'); + // Rich JSX + expect( + describeElement( + , + ), + ).toBe(''); + // Rich JSX with key + expect( + describeElement( + , + ), + ).toBe('test'); +}); + +test('Scoped Tracked button', () => { + const rendering = render( + + + + + + + , + ); + + 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 ( + + + + + + + + ); + }, + }); + + fireEvent.click(res.renderer.getByTestId('test')); + expect(events[0].scope).toEqual('plugin:TestPlugin:outer:inner'); +}); + +test('withScope - fn', () => { + const MyCoolComponent = withTrackingScope(function MyCoolComponent() { + return ( + + + + ); + }); + + const res = TestUtils.renderPlugin({ + plugin() { + return {}; + }, + Component() { + return ; + }, + }); + + 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 ( + + + + ); + } + }, + ); + + const res = TestUtils.renderPlugin({ + plugin() { + return {}; + }, + Component() { + return ; + }, + }); + + fireEvent.click(res.renderer.getByTestId('test')); + expect(events[0].scope).toEqual('plugin:TestPlugin:MyCoolComponent'); +}); diff --git a/desktop/flipper-plugin/src/utils/sleep.tsx b/desktop/flipper-plugin/src/utils/sleep.tsx new file mode 100644 index 000000000..312ce9cc1 --- /dev/null +++ b/desktop/flipper-plugin/src/utils/sleep.tsx @@ -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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index 845bae1e5..8e593c1b5 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -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.