feat: Add GA integration, support for GA4 (#4626)

* GA integration settings

* trackingId -> measurementId
Hook up script

* Public page GA tracking
Correct layout of settings

* Remove multiple codepaths for loading GA measurementID, add missing db index

* Remove unneccessary changes, tsc

* test
This commit is contained in:
Tom Moor
2023-01-01 15:29:08 +00:00
committed by GitHub
parent dc795604a4
commit 8e4270c321
29 changed files with 374 additions and 114 deletions

View File

@@ -1,8 +1,11 @@
/* global ga */
import { escape } from "lodash";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env";
const Analytics: React.FC = ({ children }) => {
// Google Analytics 3
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
@@ -17,9 +20,6 @@ const Analytics: React.FC = ({ children }) => {
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", {
dimension1: "true",
});
ga("send", "pageview");
const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js";
@@ -30,9 +30,28 @@ const Analytics: React.FC = ({ children }) => {
ga("send", "event", "pwa", "install");
});
if (document.body) {
document.body.appendChild(script);
document.body?.appendChild(script);
}, []);
// Google Analytics 4
React.useEffect(() => {
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
return;
}
const measurementId = escape(env.analytics.settings?.measurementId);
window.dataLayer = window.dataLayer || [];
function gtag(...args: any[]) {
window.dataLayer.push(args);
}
gtag("js", new Date());
gtag("config", measurementId);
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
script.async = true;
document.body?.appendChild(script);
}, []);
return <>{children}</>;

View File

@@ -11,7 +11,7 @@ import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useSettingsActions from "~/hooks/useSettingsActions";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
import { metaDisplay } from "~/utils/keyboard";

View File

@@ -0,0 +1,25 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
};
export default function GoogleIcon({
size = 24,
color = "currentColor",
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="-8 -8 48 48"
version="1.1"
>
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
</svg>
);
}

View File

@@ -7,7 +7,7 @@ import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import Sidebar from "./Sidebar";
@@ -21,7 +21,7 @@ import Version from "./components/Version";
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const configs = useAuthorizedSettingsConfig();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const returnToApp = React.useCallback(() => {

View File

@@ -3,10 +3,10 @@ import * as React from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history";
import useAuthorizedSettingsConfig from "./useAuthorizedSettingsConfig";
import useSettingsConfig from "./useSettingsConfig";
const useSettingsActions = () => {
const config = useAuthorizedSettingsConfig();
const config = useSettingsConfig();
const actions = React.useMemo(() => {
return config.map((item) => {
const Icon = item.icon;

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import GoogleAnalytics from "~/scenes/Settings/GoogleAnalytics";
import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
@@ -32,6 +33,7 @@ import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
import Webhooks from "~/scenes/Settings/Webhooks";
import Zapier from "~/scenes/Settings/Zapier";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import SlackIcon from "~/components/Icons/SlackIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import env from "~/env";
@@ -55,7 +57,8 @@ type SettingsPage =
| "Export"
| "Webhooks"
| "Slack"
| "Zapier";
| "Zapier"
| "GoogleAnalytics";
export type ConfigItem = {
name: string;
@@ -70,7 +73,7 @@ type ConfigType = {
[key in SettingsPage]: ConfigItem;
};
const useAuthorizedSettingsConfig = () => {
const useSettingsConfig = () => {
const team = useCurrentTeam();
const can = usePolicy(team);
const { t } = useTranslation();
@@ -199,6 +202,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"),
icon: SlackIcon,
},
GoogleAnalytics: {
name: t("Google Analytics"),
path: "/settings/integrations/google-analytics",
component: GoogleAnalytics,
enabled: can.update,
group: t("Integrations"),
icon: GoogleIcon,
},
Zapier: {
name: "Zapier",
path: "/settings/integrations/zapier",
@@ -215,7 +226,6 @@ const useAuthorizedSettingsConfig = () => {
can.createImport,
can.createExport,
can.createWebhookSubscription,
team.collaborativeEditing,
]
);
@@ -232,4 +242,4 @@ const useAuthorizedSettingsConfig = () => {
return enabledConfigs;
};
export default useAuthorizedSettingsConfig;
export default useSettingsConfig;

View File

@@ -1,14 +1,18 @@
import { observable } from "mobx";
import type { IntegrationSettings } from "@shared/types";
import type {
IntegrationService,
IntegrationSettings,
IntegrationType,
} from "@shared/types";
import BaseModel from "~/models/BaseModel";
import Field from "./decorators/Field";
class Integration<T = unknown> extends BaseModel {
id: string;
type: string;
type: IntegrationType;
service: string;
service: IntegrationService;
collectionId: string;

View File

@@ -2,10 +2,10 @@ import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import useSettingsConfig from "~/hooks/useSettingsConfig";
export default function SettingsRoutes() {
const configs = useAuthorizedSettingsConfig();
const configs = useSettingsConfig();
return (
<Switch>

View File

@@ -0,0 +1,115 @@
import { find } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import SettingRow from "./components/SettingRow";
type FormData = {
measurementId: string;
};
function GoogleAnalytics() {
const { integrations } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
service: IntegrationService.GoogleAnalytics,
}) as Integration<IntegrationType.Analytics> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
measurementId: integration?.settings.measurementId,
},
});
React.useEffect(() => {
integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.measurementId) {
await integrations.save({
id: integration?.id,
type: IntegrationType.Analytics,
service: IntegrationService.GoogleAnalytics,
settings: {
measurementId: data.measurementId,
},
});
} else {
await integration?.delete();
}
showToast(t("Settings saved"), {
type: "success",
});
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[integrations, integration, t, showToast]
);
return (
<Scene
title={t("Google Analytics")}
icon={<GoogleIcon color="currentColor" />}
>
<Heading>{t("Google Analytics")}</Heading>
<Text type="secondary">
<Trans>
Add a Google Analytics 4 measurement ID to send document views and
analytics from the workspace to your own Google Analytics account.
</Trans>
</Text>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Measurement ID")}
name="measurementId"
description={t(
'Create a "Web" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.'
)}
border={false}
>
<Input placeholder="G-XXXXXXXXX1" {...register("measurementId")} />
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(GoogleAnalytics);

View File

@@ -3,16 +3,16 @@ import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationType } from "@shared/types";
import { useTranslation } from "react-i18next";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
@@ -25,7 +25,7 @@ function SelfHosted() {
const integration = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: "diagrams",
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const {
@@ -53,14 +53,18 @@ function SelfHosted() {
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await integrations.save({
id: integration?.id,
type: IntegrationType.Embed,
service: "diagrams",
settings: {
url: data.drawIoUrl,
},
});
if (data.drawIoUrl) {
await integrations.save({
id: integration?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integration?.delete();
}
showToast(t("Settings saved"), {
type: "success",
@@ -81,27 +85,26 @@ function SelfHosted() {
>
<Heading>{t("Self Hosted")}</Heading>
<Text type="secondary">
<Trans>
Add your self-hosted draw.io installation url here to enable automatic
embedding of diagrams within documents.
</Trans>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<p>
<Input
label={t("Draw.io deployment")}
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl", {
required: true,
})}
/>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</p>
</form>
</Text>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}

View File

@@ -1,5 +1,6 @@
import { filter } from "lodash";
import { computed } from "mobx";
import { IntegrationService } from "@shared/types";
import naturalSort from "@shared/utils/naturalSort";
import BaseStore from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore";
@@ -18,7 +19,7 @@ class IntegrationsStore extends BaseStore<Integration> {
@computed
get slackIntegrations(): Integration[] {
return filter(this.orderedData, {
service: "slack",
service: IntegrationService.Slack,
});
}
}

View File

@@ -1,5 +1,7 @@
declare global {
interface Window {
dataLayer: any[];
DesktopBridge: {
/**
* The name of the platform running on.