diff --git a/src/components/Layout/links.ts b/src/components/Layout/links.ts index b3bbc0b8f..c0101286b 100644 --- a/src/components/Layout/links.ts +++ b/src/components/Layout/links.ts @@ -36,6 +36,12 @@ export const linkData: LinkData[] = [ type: "link", versions: [GAVersion.UniversalAnalytics], }, + { + text: "Account Explorer", + href: "/ga4/account-explorer/", + type: "link", + versions: [GAVersion.GoogleAnalytics4], + }, { text: "Campaign URL Builder", href: "/campaign-url-builder/", diff --git a/src/components/ga4/AccountExplorer/ExploreTable.tsx b/src/components/ga4/AccountExplorer/ExploreTable.tsx new file mode 100644 index 000000000..5ab890197 --- /dev/null +++ b/src/components/ga4/AccountExplorer/ExploreTable.tsx @@ -0,0 +1,238 @@ +import { CopyIconButton } from "@/components/CopyButton" +import Spinner from "@/components/Spinner" +import { + Box, + makeStyles, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from "@material-ui/core" +import { Android, Apple, Language } from "@material-ui/icons" +import React from "react" +import { AccountProperty } from "../StreamPicker/useAccountProperty" +import useAllAPS from "./useAllAPS" + +interface ExploreTableProps extends AccountProperty {} + +const WebCell: React.FC<{ + stream: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaWebDataStream +}> = ({ stream }) => { + return ( + <> + + + {stream.displayName} + + {stream.name?.substring(stream.name?.lastIndexOf("/") + 1)} + + + + ) +} + +const AndroidCell: React.FC<{ + stream: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaAndroidAppDataStream +}> = ({ stream }) => { + return ( + <> + + + + {stream.displayName || stream.packageName} + + + {stream.name?.substring(stream.name?.lastIndexOf("/") + 1)} + + + + ) +} + +const IOSCell: React.FC<{ + stream: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaIosAppDataStream +}> = ({ stream }) => { + return ( + <> + + + + {stream.displayName || stream.bundleId} + + + {stream.name?.substring(stream.name?.lastIndexOf("/") + 1)} + + + + ) +} + +const useStyles = makeStyles(theme => ({ + streamCell: { + display: "flex", + alignItems: "center", + "&> svg": { + marginRight: theme.spacing(1), + }, + "&> button": { + marginLeft: "auto", + }, + "&> div > p": { + margin: "unset", + padding: "unset", + }, + }, +})) + +const ExploreTable: React.FC = ({ account, property }) => { + const aps = useAllAPS() + const classes = useStyles() + + if (aps === undefined) { + return Loading accounts + } + + return ( + + + + Account + Property + Stream + + + + {aps.map(a => ( + + {a.propertySummaries.flatMap(p => { + const Wrapper: React.FC = ({ children }) => ( + + + + + {a.displayName} + + {a.name?.substring(a.name?.lastIndexOf("/") + 1)} + + + + + + + + + {p.displayName} + + {p.property?.substring( + p.property?.lastIndexOf("/") + 1 + )} + + + + + + + {children} + + + ) + + const rows: JSX.Element[] = [] + const baseKey = `${a.account}-${p.property}` + + if (account !== undefined) { + if (a.account !== account.account) { + return null + } + if (property !== undefined) { + if (p.property !== property.property) { + return null + } + } + } + if (p.webStreams === undefined) { + rows.push( + Loading... + ) + } else { + p.webStreams.forEach(s => + rows.push( + + + + + ) + ) + } + + if (p.androidStreams === undefined) { + rows.push( + + Loading... + + ) + } else { + p.androidStreams.forEach(s => + rows.push( + + + + + ) + ) + } + + if (p.iosStreams === undefined) { + rows.push( + Loading... + ) + } else { + p.iosStreams.forEach(s => + rows.push( + + + + + ) + ) + } + + return rows + })} + + ))} + +
+ ) +} + +export default ExploreTable diff --git a/src/components/ga4/AccountExplorer/index.tsx b/src/components/ga4/AccountExplorer/index.tsx new file mode 100644 index 000000000..2da1f8dc0 --- /dev/null +++ b/src/components/ga4/AccountExplorer/index.tsx @@ -0,0 +1,33 @@ +import React from "react" + +import { Typography } from "@material-ui/core" +import { StorageKey } from "@/constants" +import StreamPicker from "../StreamPicker" +import ExploreTable from "./ExploreTable" +import useAccountProperty from "../StreamPicker/useAccountProperty" + +enum QueryParam { + Account = "a", + Property = "b", + Stream = "c", +} + +const AccountExplorer = () => { + const ap = useAccountProperty(StorageKey.ga4AccountExplorerAPS, QueryParam) + + return ( + <> + Overview + + Use this tool to browse through your Google Analytics 4 accounts, + properties, and streams, See what accounts you have access to and find + the IDs that you need for APIs or other tools or services that integrate + with Google Analytics 4. + + + + + ) +} + +export default AccountExplorer diff --git a/src/components/ga4/AccountExplorer/useAllAPS.ts b/src/components/ga4/AccountExplorer/useAllAPS.ts new file mode 100644 index 000000000..9831810b0 --- /dev/null +++ b/src/components/ga4/AccountExplorer/useAllAPS.ts @@ -0,0 +1,172 @@ +import { StorageKey } from "@/constants" +import { RequestStatus } from "@/types" +import { AccountSummary, PropertySummary } from "@/types/ga4/StreamPicker" +import moment from "moment" +import { useEffect, useMemo, useRef, useState } from "react" +import { useSelector } from "react-redux" +import useAccountSummaries from "../StreamPicker/useAccounts" + +const maxAge = moment.duration(30, "minutes") +type StreamsForProperty = { + webStreams: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaWebDataStream[] + iosStreams: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaIosAppDataStream[] + androidStreams: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaAndroidAppDataStream[] + property: string + timestamp: number +} + +const fetchAllStreams = async ( + adminAPI: typeof gapi.client.analyticsadmin, + property: string, + key: string +) => { + const [ + { + result: { webDataStreams: webStreams = [] }, + }, + { + result: { iosAppDataStreams: iosStreams = [] }, + }, + { + result: { androidAppDataStreams: androidStreams = [] }, + }, + ] = await Promise.all([ + adminAPI.properties.webDataStreams.list({ + parent: property, + }), + adminAPI.properties.iosAppDataStreams.list({ + parent: property, + }), + adminAPI.properties.androidAppDataStreams.list({ + parent: property, + }), + ]) + // TODO - consider handling pagination, though it seems very unlikely there + // will be _pages_ of streams for a given property. + const fetchTime = moment.now() + const nu: StreamsForProperty = { + webStreams, + iosStreams, + androidStreams, + property, + timestamp: fetchTime, + } + + window.localStorage.setItem(key, JSON.stringify(nu)) + + return nu +} + +interface PropertyWithStreams extends PropertySummary { + webStreams?: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaWebDataStream[] + androidStreams?: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaAndroidAppDataStream[] + iosStreams?: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaIosAppDataStream[] +} + +export interface AccountWithStreams extends AccountSummary { + propertySummaries: Array +} + +const useAllAPS = () => { + const gapi = useSelector((a: AppState) => a.gapi) + const adminAPI = useMemo(() => gapi?.client?.analyticsadmin, [gapi]) + const accountSummariesRequest = useAccountSummaries() + const accounts = useMemo( + () => + accountSummariesRequest.status === RequestStatus.Successful + ? accountSummariesRequest.accounts + : undefined, + [accountSummariesRequest] + ) + const [allAPS, setAllAPS] = useState( + accounts as AccountWithStreams[] + ) + + const shouldRun = useRef(true) + + useEffect(() => { + shouldRun.current = true + if (adminAPI === undefined || accounts === undefined) { + return + } + if (accounts.length === 0) { + return + } + ;(async () => { + for (let accountIdx = 0; accountIdx < accounts.length; accountIdx++) { + if (shouldRun.current) { + const currentAccount = accounts[accountIdx] + const currentProperties = currentAccount.propertySummaries + if ( + currentProperties === undefined || + currentProperties.length === 0 + ) { + return + } + for ( + let propertyIdx = 0; + propertyIdx < currentProperties.length; + propertyIdx++ + ) { + if (shouldRun.current) { + const currentProperty = currentProperties[propertyIdx] + + const key = StorageKey.ga4Streams + currentProperty.property! + const fromCache = window.localStorage.getItem(key) + let stuff: StreamsForProperty + if (fromCache !== null) { + const parsed: StreamsForProperty = JSON.parse(fromCache) + const now = moment() + const cacheTime = moment(parsed.timestamp) + if ( + cacheTime === undefined || + now.isAfter(moment(cacheTime).add(maxAge)) + ) { + console.log("should update") + stuff = await fetchAllStreams( + adminAPI, + currentProperty.property!, + key + ) + } else { + stuff = parsed + } + } else { + stuff = await fetchAllStreams( + adminAPI, + currentProperty.property!, + key + ) + } + const { webStreams, androidStreams, iosStreams } = stuff + setAllAPS((old = []) => { + return old.map((aws, aIdx) => + aIdx === accountIdx + ? { + ...aws, + propertySummaries: ( + aws.propertySummaries || [] + ).map((pws, pIdx) => + pIdx === propertyIdx + ? { ...pws, iosStreams, androidStreams, webStreams } + : pws + ), + } + : aws + ) + }) + } + } + } + } + })() + + return () => { + shouldRun.current = false + } + }, [accounts, adminAPI, setAllAPS]) + + return allAPS +} + +export default useAllAPS diff --git a/src/constants.ts b/src/constants.ts index 03c1e02af..102db4171 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -248,6 +248,11 @@ export enum StorageKey { ga4EventBuilderItems = "ga4/event-builder/items", ga4EventBuilderEventName = "ga4/event-builder/event-name", ga4EventBuilderUserProperties = "ga4/event-builder/user-properties", + + // GA4 Account Explorer + ga4AccountExplorerAPS = "ga4/account-explorer/aps", + allAPS = "ga4/account-explorer/all-aps", + ga4Streams = "ga4/account-explorer/streams-for-property/", } export const EventAction = { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c4470680c..31a621dd0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -200,6 +200,11 @@ const getRedirectInfo = ( redirectPath: "/", toast: "Redirecting to the UA home page.", } + case "/ga4/account-explorer/": + return { + redirectPath: "/account-explorer/", + toast: uaToast("Account Explorer"), + } default: return { redirectPath: "/", @@ -249,6 +254,11 @@ const getRedirectInfo = ( redirectPath: "/ga4/", toast: "Redirecting to the GA4 home page.", } + case "/account-explorer/": + return { + redirectPath: "/ga4/account-explorer/", + toast: ga4Toast("Account Explorer"), + } default: return { redirectPath: "/ga4/", diff --git a/src/pages/ga4/account-explorer/index.tsx b/src/pages/ga4/account-explorer/index.tsx new file mode 100644 index 000000000..f3c2dc61e --- /dev/null +++ b/src/pages/ga4/account-explorer/index.tsx @@ -0,0 +1,31 @@ +// Copyright 2020 Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from "react" + +import Layout from "@/components/Layout" +import AccountExplorer from "@/components/ga4/AccountExplorer" + +export default ({ location: { pathname } }) => { + return ( + + + + ) +}