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 (
+
+
+
+ )
+}