chore: Add tracking for hyperlinks (#4703)

* chore: Add tracking for hyperlinks

* update

* fix

* remove

* tweak

* disable ga logging in dev again

* add logging for hyperlink `edit` & support for tracking in manager

* event label tweaks

* fix tests & make more typesafe

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2022-02-21 17:44:28 +05:30 committed by GitHub
parent e203203993
commit 21e9fcb2f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 68 additions and 13 deletions

View File

@ -10,6 +10,31 @@ import {
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants"; import { MODES } from "../constants";
import { trackEvent } from "../analytics";
const trackAction = (
action: Action,
source: "ui" | "keyboard" | "api",
value: any,
) => {
if (action.trackEvent !== false) {
try {
if (action.trackEvent === true) {
trackEvent(
action.name,
source,
typeof value === "number" || typeof value === "string"
? String(value)
: undefined,
);
} else {
action.trackEvent?.(action, source, value);
}
} catch (error) {
console.error("error while logging action:", error);
}
}
};
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@ -65,9 +90,12 @@ export class ActionManager implements ActionsManagerInterface {
), ),
); );
if (data.length === 0) { if (data.length !== 1) {
return false; return false;
} }
const action = data[0];
const { viewModeEnabled } = this.getAppState(); const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) { if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) { if (!Object.values(MODES).includes(data[0].name)) {
@ -75,6 +103,8 @@ export class ActionManager implements ActionsManagerInterface {
} }
} }
trackAction(action, "keyboard", null);
event.preventDefault(); event.preventDefault();
this.updater( this.updater(
data[0].perform( data[0].perform(
@ -96,6 +126,7 @@ export class ActionManager implements ActionsManagerInterface {
this.app, this.app,
), ),
); );
trackAction(action, "api", null);
} }
/** /**
@ -122,6 +153,8 @@ export class ActionManager implements ActionsManagerInterface {
this.app, this.app,
), ),
); );
trackAction(action, "ui", formState);
}; };
return ( return (

View File

@ -1,8 +1,10 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin } from "../keys"; import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
export type ShortcutName = export type ShortcutName = SubtypeOf<
ActionName,
| "cut" | "cut"
| "copy" | "copy"
| "paste" | "paste"
@ -26,7 +28,8 @@ export type ShortcutName =
| "viewMode" | "viewMode"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "link"; | "hyperlink"
>;
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
@ -63,7 +66,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipHorizontal: [getShortcutKey("Shift+H")], flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
link: [getShortcutKey("CtrlOrCmd+K")], hyperlink: [getShortcutKey("CtrlOrCmd+K")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@ -105,7 +105,7 @@ export type ActionName =
| "increaseFontSize" | "increaseFontSize"
| "decreaseFontSize" | "decreaseFontSize"
| "unbindText" | "unbindText"
| "link"; | "hyperlink";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@ -136,6 +136,9 @@ export interface Action {
appState: AppState, appState: AppState,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent?:
| boolean
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
} }
export interface ActionsManagerInterface { export interface ActionsManagerInterface {

View File

@ -3,16 +3,16 @@ export const trackEvent =
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" && typeof window !== "undefined" &&
window.gtag window.gtag
? (category: string, name: string, label?: string, value?: number) => { ? (category: string, action: string, label?: string, value?: number) => {
window.gtag("event", name, { window.gtag("event", action, {
event_category: category, event_category: category,
event_label: label, event_label: label,
value, value,
}); });
} }
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, name: string, label?: string, value?: number) => {} ? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => { : (category: string, action: string, label?: string, value?: number) => {
// Uncomment the next line to track locally // Uncomment the next line to track locally
// console.info("Track Event", category, name, label, value); // console.info("Track Event", category, action, label, value);
}; };

View File

@ -158,7 +158,7 @@ export const SelectedShapeActions = ({
{!isMobile && renderAction("deleteSelectedElements")} {!isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{targetElements.length === 1 && renderAction("link")} {targetElements.length === 1 && renderAction("hyperlink")}
</div> </div>
</fieldset> </fieldset>
)} )}

View File

@ -31,6 +31,7 @@ import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./"; import { getElementAbsoluteCoords } from "./";
import "./Hyperlink.scss"; import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@ -69,6 +70,10 @@ export const Hyperlink = ({
const link = normalizeLink(inputRef.current.value); const link = normalizeLink(inputRef.current.value);
if (!element.link && link) {
trackEvent("hyperlink", "create");
}
mutateElement(element, { link }); mutateElement(element, { link });
setAppState({ showHyperlinkPopup: "info" }); setAppState({ showHyperlinkPopup: "info" });
}, [element, setAppState]); }, [element, setAppState]);
@ -108,6 +113,7 @@ export const Hyperlink = ({
}, [appState, element, isEditing, setAppState]); }, [appState, element, isEditing, setAppState]);
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete");
mutateElement(element, { link: null }); mutateElement(element, { link: null });
if (isEditing) { if (isEditing) {
inputRef.current!.value = ""; inputRef.current!.value = "";
@ -116,6 +122,7 @@ export const Hyperlink = ({
}, [setAppState, element, isEditing]); }, [setAppState, element, isEditing]);
const onEdit = () => { const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui");
setAppState({ showHyperlinkPopup: "editor" }); setAppState({ showHyperlinkPopup: "editor" });
}; };
const { x, y } = getCoordsForPopover(element, appState); const { x, y } = getCoordsForPopover(element, appState);
@ -239,11 +246,12 @@ export const isLocalLink = (link: string | null) => {
}; };
export const actionLink = register({ export const actionLink = register({
name: "link", name: "hyperlink",
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") { if (appState.showHyperlinkPopup === "editor") {
return false; return false;
} }
return { return {
elements, elements,
appState: { appState: {
@ -254,6 +262,9 @@ export const actionLink = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
trackEvent: (action, source) => {
trackEvent("hyperlink", "edit", source);
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) => contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState), getContextMenuLabel(elements, appState),
@ -400,6 +411,7 @@ const renderTooltip = (
}, },
"top", "top",
); );
trackEvent("hyperlink", "tooltip", "link-icon");
IS_HYPERLINK_TOOLTIP_VISIBLE = true; IS_HYPERLINK_TOOLTIP_VISIBLE = true;
}; };

4
src/global.d.ts vendored
View File

@ -34,6 +34,10 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P]; -readonly [P in keyof T]: T[P];
}; };
/** utility type to assert that the second type is a subtype of the first type.
* Returns the subtype. */
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
type ResolutionType<T extends (...args: any) => any> = T extends ( type ResolutionType<T extends (...args: any) => any> = T extends (
...args: any ...args: any
) => Promise<infer R> ) => Promise<infer R>

View File

@ -136,7 +136,7 @@ describe("contextMenu element", () => {
"sendToBack", "sendToBack",
"bringToFront", "bringToFront",
"duplicateSelection", "duplicateSelection",
"link", "hyperlink",
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();