Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve secret selection #708

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
221 changes: 221 additions & 0 deletions src/components/ga4/EventBuilder/MPSecret/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { PAB, SAB } from "@/components/Buttons"
import ExternalLink from "@/components/ExternalLink"
import Spinner from "@/components/Spinner"
import Warning from "@/components/Warning"
import WithHelpText from "@/components/WithHelpText"
import { StorageKey, Url } from "@/constants"
import useFormStyles from "@/hooks/useFormStyles"
import { Dispatch, RequestStatus, successful } from "@/types"
import {
Dialog,
DialogTitle,
makeStyles,
TextField,
Typography,
} from "@material-ui/core"
import { Autocomplete } from "@material-ui/lab"
import * as React from "react"
import StreamPicker, { RenderOption } from "../../StreamPicker"
import useAccountPropertyStream from "../../StreamPicker/useAccountPropertyStream"
import { QueryParam } from "../types"
import useInputs, { CreationStatus } from "./useInputs"
import useMPSecretsRequest, {
MPSecret as MPSecretT,
} from "./useMPSecretsRequest"

const useStyles = makeStyles(theme => ({
mpSecret: {
"&> :not(:first-child)": {
marginTop: theme.spacing(1),
},
},
secret: {
display: "flex",
alignItems: "center",
"&> :not(:first-child)": {
marginLeft: theme.spacing(1),
},
},
createSecretDialog: {
padding: theme.spacing(1),
"&> :not(:first-child)": {
marginTop: theme.spacing(1),
},
},
}))

interface Props {
setSecret: Dispatch<MPSecretT | undefined>
secret: MPSecretT | undefined
useFirebase: boolean
}

const api_secret_reference = (
<ExternalLink href={Url.ga4MPAPISecretReference}>api_secret</ExternalLink>
)

const MPSecret: React.FC<Props> = ({ secret, setSecret, useFirebase }) => {
const formClasses = useFormStyles()
const classes = useStyles()

const aps = useAccountPropertyStream(
StorageKey.eventBuilderAPS,
QueryParam,
{
androidStreams: useFirebase,
iosStreams: useFirebase,
webStreams: !useFirebase,
},
true
)

const secretsRequest = useMPSecretsRequest({
aps,
})

React.useEffect(() => {
if (successful(secretsRequest)) {
const secrets = successful(secretsRequest)!.secrets
console.log("setting to first secret")
setSecret(secrets?.[0])
}
}, [secretsRequest])

const [creationError, setCreationError] = React.useState<any>()

const {
displayName,
setDisplayName,
creationStatus,
setCreationStatus,
} = useInputs()

return (
<section className={classes.mpSecret}>
<Typography>Choose an account, property, and stream.</Typography>
<StreamPicker
streams
{...aps}
noStreamsText={
useFirebase
? "There are no iOS or Android streams for the selected property."
: "There are no web streams for the selected property."
}
/>
<Typography>
Select an existing api_secret or create a new secret.
</Typography>
<WithHelpText
helpText={
<>
The API secret for the property to send the event to. See{" "}
{api_secret_reference} on devsite
</>
}
>
<section className={classes.secret}>
<Autocomplete<MPSecretT, false, false, true>
className={formClasses.grow}
loading={secretsRequest.status !== RequestStatus.Successful}
options={successful(secretsRequest)?.secrets || []}
noOptionsText="There are no secrets for the selected stream."
loadingText={
aps.stream === undefined
? "Choose an account, property, and stream to see existing secrets."
: "Loading..."
}
value={secret || null}
getOptionLabel={secret => secret.secretValue}
getOptionSelected={(a, b) => a.name === b.name}
onChange={(_event, value) => {
if (value === null) {
setSecret(undefined)
return
}
if (typeof value === "string") {
setSecret({ secretValue: value })
return
}
setSecret(value)
}}
renderOption={secret => (
<RenderOption
first={
(typeof secret === "string"
? "manually entered secret"
: secret.displayName) || ""
}
second={secret.secretValue}
/>
)}
renderInput={params => (
<TextField
{...params}
label="api_secret"
size="small"
variant="outlined"
/>
)}
/>
<div>
<SAB
title="Create a new secret under the current stream."
disabled={!successful(secretsRequest)}
onClick={() => {
setCreationStatus(CreationStatus.ShowDialog)
}}
>
new secret
</SAB>
</div>

<Dialog
open={
creationStatus === CreationStatus.ShowDialog ||
creationStatus === CreationStatus.Creating
}
onClose={() => setCreationStatus(CreationStatus.NotStarted)}
>
<DialogTitle>Create new secret</DialogTitle>
<section className={classes.createSecretDialog}>
{creationStatus === CreationStatus.ShowDialog ? (
<TextField
label="secret name"
variant="outlined"
size="small"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
/>
) : (
<Spinner ellipses>creating new secret</Spinner>
)}
<div>
<PAB
add
onClick={async () => {
setCreationStatus(CreationStatus.Creating)
try {
const nuSecret = await successful(
secretsRequest
)!.createMPSecret(displayName)
setCreationStatus(CreationStatus.Done)
setSecret(nuSecret)
} catch (e) {
setCreationError(e)
setCreationStatus(CreationStatus.Failed)
}
}}
>
Create
</PAB>
</div>
</section>
</Dialog>
</section>
{creationError && <Warning>{creationError?.message}</Warning>}
</WithHelpText>
</section>
)
}

export default MPSecret
42 changes: 42 additions & 0 deletions src/components/ga4/EventBuilder/MPSecret/useCreateMPSecret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Stream } from "@/types/ga4/StreamPicker"
import { useCallback } from "react"
import { useSelector } from "react-redux"

const necessaryScopes = ["https://www.googleapis.com/auth/analytics.edit"]

const useCreateMPSecret = (stream: Stream | undefined) => {
const gapi = useSelector((a: AppState) => a.gapi)
const user = useSelector((a: AppState) => a.user)
return useCallback(
async (displayName: string) => {
if (gapi === undefined || stream === undefined || user === undefined) {
return
}
try {
if (!user.hasGrantedScopes(necessaryScopes.join(","))) {
await user.grant({
scope: necessaryScopes.join(","),
})
}
// TODO - Update this once this is available in the client libraries.
const response = await gapi.client.request({
path: `https://content-analyticsadmin.googleapis.com/v1alpha/${stream.value.name}/measurementProtocolSecrets`,
method: "POST",
body: JSON.stringify({
display_name: displayName,
}),
})
return response.result
} catch (e) {
if (e?.result?.error?.message !== undefined) {
throw new Error(e.result.error.message)
} else {
throw e
}
}
},
[gapi, stream, user]
)
}

export default useCreateMPSecret
38 changes: 38 additions & 0 deletions src/components/ga4/EventBuilder/MPSecret/useGetMPSecrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Stream } from "@/types/ga4/StreamPicker"
import { useCallback, useMemo } from "react"
import { useSelector } from "react-redux"
import { MPSecret } from "./useMPSecretsRequest"

const useGetMPSecrets = (stream: Stream | undefined) => {
const gapi = useSelector((a: AppState) => a.gapi)

const requestReady = useMemo(() => {
if (gapi === undefined || stream === undefined) {
return false
}
return true
}, [gapi, stream])

const getMPSecrets = useCallback(async () => {
if (gapi === undefined || stream === undefined) {
throw new Error("Invalid invariant - gapi & stream must be defined here.")
}
try {
const response = await gapi.client.request({
path: `https://content-analyticsadmin.googleapis.com/v1alpha/${stream.value.name}/measurementProtocolSecrets`,
})
console.log({ response })
return (response.result.measurementProtocolSecrets || []) as MPSecret[]
} catch (e) {
console.error(
"There was an error getting the measurement protocol secrets.",
e
)
throw e
}
}, [gapi, stream])

return { requestReady, getMPSecrets }
}

export default useGetMPSecrets
27 changes: 27 additions & 0 deletions src/components/ga4/EventBuilder/MPSecret/useInputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useState } from "react"

import { MPSecret } from "./useMPSecretsRequest"

export enum CreationStatus {
NotStarted = "not-started",
ShowDialog = "show-dialog",
Creating = "creating",
Done = "done",
Failed = "failed",
}

const useInputs = () => {
const [displayName, setDisplayName] = useState("")
const [creationStatus, setCreationStatus] = useState<CreationStatus>(
CreationStatus.NotStarted
)

return {
displayName,
setDisplayName,
creationStatus,
setCreationStatus,
}
}

export default useInputs
98 changes: 98 additions & 0 deletions src/components/ga4/EventBuilder/MPSecret/useMPSecretsRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { StorageKey } from "@/constants"
import useCached from "@/hooks/useCached"
import useRequestStatus from "@/hooks/useRequestStatus"
import { Requestable, RequestStatus } from "@/types"
import { Stream } from "@/types/ga4/StreamPicker"
import moment from "moment"
import { useCallback, useEffect } from "react"
import useCreateMPSecret from "./useCreateMPSecret"
import useGetMPSecrets from "./useGetMPSecrets"

interface MPSecrets {
secrets: MPSecret[] | undefined
createMPSecret: (displayName: string) => Promise<MPSecret>
}

export interface MPSecret {
displayName?: string
name?: string
secretValue: string
}

interface Args {
stream: Stream | undefined
}
const useMPSecretsRequest = ({ stream }: Args): Requestable<MPSecrets> => {
const { status, setFailed, setSuccessful, setInProgress } = useRequestStatus(
RequestStatus.NotStarted
)

const {
getMPSecrets: getMPSecretsLocal,
requestReady: getMPSecretsRequestReady,
} = useGetMPSecrets(stream)

const createMPSecretLocal = useCreateMPSecret(stream)

const getMPSecrets = useCallback(async () => {
setInProgress()
return getMPSecretsLocal()
}, [getMPSecretsLocal, setInProgress])

const onError = useCallback(
(e: any) => {
setFailed()
// TODO - not sure what to do here yet.
throw e
},
[setFailed]
)

const { value: secrets, bustCache } = useCached(
`${StorageKey.eventBuilderMPSecrets}/${stream?.value.name}` as StorageKey,
getMPSecrets,
moment.duration(5, "minutes"),
getMPSecretsRequestReady,
onError
)

const createMPSecret = useCallback(
async (displayName: string) => {
const secret = await createMPSecretLocal(displayName)
await bustCache()
return secret
},
[createMPSecretLocal, bustCache]
)

useEffect(() => {
if (status !== RequestStatus.Successful && secrets !== undefined) {
setSuccessful()
}
}, [secrets, setSuccessful, status])

if (stream === undefined) {
return {
status: RequestStatus.Successful,
secrets: undefined,
createMPSecret,
}
}

if (
status === RequestStatus.NotStarted ||
status === RequestStatus.InProgress ||
status === RequestStatus.Failed
) {
return { status }
} else {
if (secrets !== undefined) {
return { status, secrets, createMPSecret }
} else {
throw new Error("Invalid invariant - secrets must be defined here.")
// return { status: RequestStatus.InProgress }
}
}
}

export default useMPSecretsRequest
34 changes: 17 additions & 17 deletions src/components/ga4/EventBuilder/ValidateEvent/useSharableLink.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useContext, useMemo } from "react"
import { EventCtx, UseFirebaseCtx } from ".."
import { MobileIds, UrlParam, WebIds } from "../types"
import { MobileIds, QueryParam, WebIds } from "../types"
import { encodeObject, ensureVersion } from "@/url"
import { URLVersion } from "@/types"

@@ -21,52 +21,52 @@ const useSharableLink = () => {

return useMemo(() => {
const params = new URLSearchParams()
ensureVersion(params, UrlParam, URLVersion._2)
ensureVersion(params, QueryParam, URLVersion._2)

const addIfTruthy = (p: UrlParam, v: any) => {
const addIfTruthy = (p: QueryParam, v: any) => {
v && params.append(p, v)
}

useFirebase !== undefined &&
params.append(UrlParam.UseFirebase, useFirebase ? "1" : "0")
params.append(QueryParam.UseFirebase, useFirebase ? "1" : "0")

non_personalized_ads !== undefined &&
params.append(
UrlParam.NonPersonalizedAds,
QueryParam.NonPersonalizedAds,
non_personalized_ads ? "1" : "0"
)

addIfTruthy(
UrlParam.AppInstanceId,
QueryParam.AppInstanceId,
(clientIds as MobileIds).app_instance_id
)

addIfTruthy(UrlParam.EventType, type)
addIfTruthy(QueryParam.EventType, type)

addIfTruthy(UrlParam.EventName, eventName)
addIfTruthy(QueryParam.EventName, eventName)

addIfTruthy(UrlParam.ClientId, (clientIds as WebIds).client_id)
addIfTruthy(QueryParam.ClientId, (clientIds as WebIds).client_id)

addIfTruthy(UrlParam.UserId, clientIds.user_id)
addIfTruthy(QueryParam.UserId, clientIds.user_id)

addIfTruthy(UrlParam.APISecret, api_secret)
addIfTruthy(QueryParam.APISecret, api_secret)

addIfTruthy(UrlParam.MeasurementId, instanceId.measurement_id)
addIfTruthy(QueryParam.MeasurementId, instanceId.measurement_id)

addIfTruthy(UrlParam.FirebaseAppId, instanceId.firebase_app_id)
addIfTruthy(QueryParam.FirebaseAppId, instanceId.firebase_app_id)

addIfTruthy(UrlParam.TimestampMicros, timestamp_micros)
addIfTruthy(QueryParam.TimestampMicros, timestamp_micros)

if (userProperties) {
params.append(UrlParam.UserProperties, encodeObject(userProperties))
params.append(QueryParam.UserProperties, encodeObject(userProperties))
}

if (items) {
params.append(UrlParam.Items, encodeObject(items))
params.append(QueryParam.Items, encodeObject(items))
}

if (parameters.length > 0) {
params.append(UrlParam.Parameters, encodeObject(parameters))
params.append(QueryParam.Parameters, encodeObject(parameters))
}

const urlParams = params.toString()
8 changes: 8 additions & 0 deletions src/components/ga4/EventBuilder/index.tsx
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@ import { eventsForCategory } from "./event"
import useUserProperties from "./useUserProperties"
import Items from "./Items"
import ValidateEvent from "./ValidateEvent"
import MPSecret from "./MPSecret"

export enum Label {
APISecret = "api_secret",
@@ -171,6 +172,8 @@ const EventBuilder: React.FC = () => {
setTimestampMicros,
non_personalized_ads,
setNonPersonalizedAds,
secret,
setSecret,
} = useInputs(categories)

return (
@@ -214,6 +217,11 @@ const EventBuilder: React.FC = () => {
</Typography>

<section className={formClasses.form}>
<MPSecret
secret={secret}
setSecret={setSecret}
useFirebase={useFirebase}
/>
<LinkedTextField
required
href="https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference#api_secret"
31 changes: 17 additions & 14 deletions src/components/ga4/EventBuilder/types.ts
Original file line number Diff line number Diff line change
@@ -83,7 +83,7 @@ export interface Event2 {
}

// TODO - Add test to ensure url param values are all unique.
export enum UrlParam {
export enum QueryParam {
Parameters = "a",
Items = "b",
EventType = "c",
@@ -100,6 +100,9 @@ export enum UrlParam {
ClientId = "n",
AppInstanceId = "o",
Version = "p",
Account = "q",
Property = "r",
Stream = "s",
}

export enum ValidationStatus {
@@ -133,17 +136,17 @@ export interface InstanceId {
}

export interface URLParts {
[UrlParam.EventName]?: string
[UrlParam.UseFirebase]?: boolean
[UrlParam.ClientId]?: string
[UrlParam.AppInstanceId]?: string
[UrlParam.UserId]?: string
[UrlParam.Parameters]?: Parameter[]
[UrlParam.UserProperties]?: Parameter[]
[UrlParam.Items]?: Parameter[][]
[UrlParam.MeasurementId]?: string
[UrlParam.FirebaseAppId]?: string
[UrlParam.APISecret]?: string
[UrlParam.TimestampMicros]?: string
[UrlParam.NonPersonalizedAds]?: boolean
[QueryParam.EventName]?: string
[QueryParam.UseFirebase]?: boolean
[QueryParam.ClientId]?: string
[QueryParam.AppInstanceId]?: string
[QueryParam.UserId]?: string
[QueryParam.Parameters]?: Parameter[]
[QueryParam.UserProperties]?: Parameter[]
[QueryParam.Items]?: Parameter[][]
[QueryParam.MeasurementId]?: string
[QueryParam.FirebaseAppId]?: string
[QueryParam.APISecret]?: string
[QueryParam.TimestampMicros]?: string
[QueryParam.NonPersonalizedAds]?: boolean
}
12 changes: 6 additions & 6 deletions src/components/ga4/EventBuilder/useEvent.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import {
EventType,
Parameter,
ParameterType,
UrlParam,
QueryParam,
} from "@/components/ga4/EventBuilder/types"
import {
cloneEvent,
@@ -79,7 +79,7 @@ const ItemsParam: QueryParamConfig<Parameter[][] | undefined | null> = {

const getVersion = (): string => {
const urlParams = new URLSearchParams(window.location.search)
const version = urlParams.get(UrlParam.Version)
const version = urlParams.get(QueryParam.Version)
if (version === null) {
return "1"
}
@@ -120,29 +120,29 @@ export const ParametersParam: QueryParamConfig<
const useEvent = (initial?: EventType) => {
const [typeString, setTypeLocal] = useHydratedPersistantString(
StorageKey.ga4EventBuilderLastEventType,
UrlParam.EventType,
QueryParam.EventType,
initial || EventType.SelectContent
)

const type = useMemo(() => typeString as EventType, [typeString])

const [eventName, setEventName] = useHydratedPersistantString(
StorageKey.ga4EventBuilderEventName,
UrlParam.EventName
QueryParam.EventName
)

const categories = useMemo(() => suggestedEventFor(type).categories, [type])

const [parameters, setParameters] = useHydratedPersistantObject(
StorageKey.ga4EventBuilderParameters,
UrlParam.Parameters,
QueryParam.Parameters,
ParametersParam,
suggestedEventFor(type).parameters
)

const [items, setItems] = useHydratedPersistantObject(
StorageKey.ga4EventBuilderItems,
UrlParam.Items,
QueryParam.Items,
ItemsParam
)

25 changes: 15 additions & 10 deletions src/components/ga4/EventBuilder/useInputs.ts
Original file line number Diff line number Diff line change
@@ -4,43 +4,44 @@ import {
useHydratedPersistantString,
} from "@/hooks/useHydrated"
import { useState } from "react"
import { Category, UrlParam } from "./types"
import { MPSecret } from "./MPSecret/useMPSecretsRequest"
import { Category, QueryParam } from "./types"

const useInputs = (categories: Category[]) => {
const [useFirebase, setUseFirebase] = useHydratedPersistantBoolean(
StorageKey.eventBuilderUseFirebase,
UrlParam.UseFirebase,
QueryParam.UseFirebase,
true
)

const [api_secret, setAPISecret] = useHydratedPersistantString(
StorageKey.eventBuilderApiSecret,
UrlParam.APISecret
QueryParam.APISecret
)

const [firebase_app_id, setFirebaseAppId] = useHydratedPersistantString(
StorageKey.eventBuilderFirebaseAppId,
UrlParam.FirebaseAppId
QueryParam.FirebaseAppId
)

const [measurement_id, setMeasurementId] = useHydratedPersistantString(
StorageKey.eventBuilderMeasurementId,
UrlParam.MeasurementId
QueryParam.MeasurementId
)

const [client_id, setClientId] = useHydratedPersistantString(
StorageKey.eventBuilderClientId,
UrlParam.ClientId
QueryParam.ClientId
)

const [app_instance_id, setAppInstanceId] = useHydratedPersistantString(
StorageKey.eventBuilderAppInstanceId,
UrlParam.AppInstanceId
QueryParam.AppInstanceId
)

const [user_id, setUserId] = useHydratedPersistantString(
StorageKey.eventBuilderUserId,
UrlParam.UserId
QueryParam.UserId
)

const [category, setCategory] = useState(categories[0])
@@ -50,15 +51,17 @@ const useInputs = (categories: Category[]) => {
setNonPersonalizedAds,
] = useHydratedPersistantBoolean(
StorageKey.eventBuilderNonPersonalizedAds,
UrlParam.NonPersonalizedAds,
QueryParam.NonPersonalizedAds,
false
)

const [timestamp_micros, setTimestampMicros] = useHydratedPersistantString(
StorageKey.eventBuilderTimestampMicros,
UrlParam.TimestampMicros
QueryParam.TimestampMicros
)

const [secret, setSecret] = useState<MPSecret>()

return {
useFirebase,
setUseFirebase,
@@ -80,6 +83,8 @@ const useInputs = (categories: Category[]) => {
setNonPersonalizedAds,
timestamp_micros,
setTimestampMicros,
secret,
setSecret,
}
}

4 changes: 2 additions & 2 deletions src/components/ga4/EventBuilder/useUserProperties.ts
Original file line number Diff line number Diff line change
@@ -3,15 +3,15 @@ import { useAddToArray, useRemoveByIndex, useUpdateByIndex } from "@/hooks"
import { useHydratedPersistantObject } from "@/hooks/useHydrated"
import { useCallback } from "react"
import { numberParam, stringParam } from "./event"
import { Parameter, UrlParam } from "./types"
import { Parameter, QueryParam } from "./types"
import { ParametersParam } from "./useEvent"

const useUserProperties = () => {
const [userProperties, setUserProperties] = useHydratedPersistantObject<
Parameter[]
>(
StorageKey.ga4EventBuilderUserProperties,
UrlParam.UserProperties,
QueryParam.UserProperties,
ParametersParam
)

21 changes: 5 additions & 16 deletions src/components/ga4/StreamPicker/index.tsx
Original file line number Diff line number Diff line change
@@ -29,26 +29,24 @@ interface CommonProps {
property: PropertySummary | undefined
setAccountID: Dispatch<string | undefined>
setPropertyID: Dispatch<string | undefined>
autoFill?: boolean
}

interface WithStreams extends CommonProps {
// If needed this can be updated to only show web, firebase, or ios streams.
streams: true
stream: Stream | undefined
setStreamID: Dispatch<string | undefined>
streamsRequest: Requestable<{ streams: Stream[] }>
updateToFirstStream: () => void
noStreamsText?: string
}

interface OnlyProperty extends CommonProps {
streams?: false | undefined
}

type StreamPickerProps = OnlyProperty | WithStreams
export type StreamPickerProps = OnlyProperty | WithStreams

const StreamPicker: React.FC<StreamPickerProps> = props => {
const { account, property, setAccountID, setPropertyID, autoFill } = props
const { account, property, setAccountID, setPropertyID } = props
const classes = useStyles()

const accountsAndPropertiesRequest = useAccountsAndProperties(account)
@@ -66,12 +64,6 @@ const StreamPicker: React.FC<StreamPickerProps> = props => {
getOptionSelected={(a, b) => a.name === b.name}
onChange={(_event, value) => {
setAccountID(value === null ? undefined : value?.name)

if (autoFill) {
const property = value?.propertySummaries?.[0]
setPropertyID(property?.property)
props.streams && props.updateToFirstStream()
}
}}
renderOption={account => (
<RenderOption
@@ -100,10 +92,6 @@ const StreamPicker: React.FC<StreamPickerProps> = props => {
onChange={(_event, value) => {
const property = value === null ? undefined : value
setPropertyID(property?.property)

if (autoFill) {
props.streams && props.updateToFirstStream()
}
}}
renderOption={summary => (
<RenderOption
@@ -129,7 +117,8 @@ const StreamPicker: React.FC<StreamPickerProps> = props => {
noOptionsText={
property === undefined
? "Select an account an property to populate this dropdown."
: "There are no streams for the selected property."
: props.noStreamsText ||
"There are no streams for the selected property."
}
value={props.stream || null}
getOptionLabel={stream => stream.value.displayName!}
210 changes: 210 additions & 0 deletions src/components/ga4/StreamPicker/useAccountProperty.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import "@testing-library/jest-dom"
import { renderHook } from "@testing-library/react-hooks"

import useAccountProperty from "./useAccountProperty"
import { wrapperFor } from "@/test-utils"
import { StorageKey } from "@/constants"
import moment from "moment"

enum QueryParam {
Account = "a",
Property = "b",
Stream = "c",
}

describe("useAccountProperty hook", () => {
describe("with accountSummaries cached locally", () => {
const accountID = "account-id"
const propertyID = "property-id"

beforeEach(() => {
window.localStorage.clear()
const summaries: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaAccountSummary[] = [
{
name: accountID,
displayName: "my account",
account: "accounts/my-account",
propertySummaries: [
{
property: propertyID,
displayName: "my property",
},
],
},
]
window.localStorage.setItem(
StorageKey.ga4AccountSummaries,
JSON.stringify({ value: summaries, "@@_last_fetched": moment.now() })
)
})

test("with Account & Property values in localStorage", async () => {
const storageKey = "a" as StorageKey
window.localStorage.setItem(
"a-account",
JSON.stringify({ value: accountID })
)
window.localStorage.setItem(
"a-property",
JSON.stringify({ value: propertyID })
)

const { result } = renderHook(
() => useAccountProperty(storageKey, QueryParam),
{ wrapper: wrapperFor({}) }
)

expect(result.current.account).not.toBeUndefined()
expect(result.current.account!.name).toBe(accountID)

expect(result.current.property).not.toBeUndefined()
expect(result.current.property!.property).toBe(propertyID)
})
})
// test("with Account & Property values already saved in localStorage", async () => {
// const accountID = "account-id"
// window.localStorage.setItem(
// "a-account",
// JSON.stringify({ value: accountID })
// )
// const accountSummariesMock = jest.fn<
// Promise<{
// result: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListAccountSummariesResponse
// }>,
// Parameters<typeof gapi.client.analyticsadmin.accountSummaries.list>
// >(() =>
// Promise.resolve({
// result: {
// accountSummaries: [
// {
// account: accountID,
// displayName: "My first account",
// propertySummaries: [
// { property: "property-id", displayName: "My first property" },
// ],
// },
// ],
// },
// })
// )
// const { result, waitForNextUpdate } = renderHook(
// () => useAccountProperty("a" as StorageKey, QueryParam),
// {
// wrapper: wrapperFor({
// gapi: {
// client: {
// analyticsadmin: {
// accountSummaries: { list: accountSummariesMock as any },
// },
// },
// },
// }),
// }
// )

// expect(result.current.account).toBeUndefined()
// expect(result.current.property).toBeUndefined()

// await act(async () => {
// await waitForNextUpdate()
// await waitForNextUpdate()
// })

// expect(result.current.account).not.toBeUndefined()
// })
// test("defaults to selectContent", () => {
// const { result } = renderHook(() => useEvent(), options)
// expect(result.current.type).toBe(EventType.SelectContent)
// })

// describe("when changing event type", () => {
// // TODO - add this test back in once the keepCommonParameters fix is done.
// // test("keeps values of common parameters", async () => {
// // const { result } = renderHook(
// // () => useEvent(EventType.SelectContent),
// // options
// // )

// // act(() => {
// // const idx = result.current.parameters.findIndex(
// // parameter => parameter.name === "content_type"
// // )
// // if (idx === -1) {
// // fail("select content is expected to have a 'content_type' parameter.")
// // }
// // result.current.setParamValue(idx, "image")
// // result.current.setType(EventType.Share)
// // })

// // expect(result.current.type).toBe(EventType.Share)
// // const idx = result.current.parameters.findIndex(
// // p => p.name === "content_type"
// // )
// // expect(idx).not.toBe(-1)
// // expect(result.current.parameters[idx].value).toBe("image")
// // })
// test("supports every event type", () => {
// const { result } = renderHook(
// () => useEvent(EventType.SelectContent),
// options
// )

// act(() => {
// Object.values(EventType).forEach(eventType => {
// result.current.setType(eventType)
// })
// })
// })
// describe("with no parameters in common", () => {
// test("only keeps new parameters", () => {
// // SelectContent and EarnVirtualCurrency have no parameters in common.
// const { result } = renderHook(
// () => useEvent(EventType.SelectContent),
// options
// )

// act(() => {
// result.current.setType(EventType.EarnVirtualCurrency)
// })

// const expectedParams = cloneEvent(
// suggestedEventFor(EventType.EarnVirtualCurrency)
// ).parameters
// const actualParams = result.current.parameters

// expect(actualParams).toHaveLength(expectedParams.length)
// actualParams.forEach((actualP, idx) => {
// const expectedP = expectedParams[idx]
// expect(actualP.name).toBe(expectedP.name)
// expect(actualP.value).toBe(expectedP.value)
// expect(actualP.type).toBe(expectedP.type)
// })
// })
// test("double swap only keeps new parameters", () => {
// // SelectContent and EarnVirtualCurrency have no parameters in common.
// const { result } = renderHook(
// () => useEvent(EventType.SelectContent),
// options
// )

// act(() => {
// result.current.setType(EventType.EarnVirtualCurrency)
// result.current.setType(EventType.SelectContent)
// })

// const expectedParams = cloneEvent(
// suggestedEventFor(EventType.SelectContent)
// ).parameters
// const actualParams = result.current.parameters

// expect(actualParams).toHaveLength(expectedParams.length)
// actualParams.forEach((actualP, idx) => {
// const expectedP = expectedParams[idx]
// expect(actualP.name).toBe(expectedP.name)
// expect(actualP.value).toBe(expectedP.value)
// expect(actualP.type).toBe(expectedP.type)
// })
// })
// })
// })
})
22 changes: 21 additions & 1 deletion src/components/ga4/StreamPicker/useAccountProperty.ts
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@ export interface AccountPropertySetters {
const useAccountProperty = (
prefix: StorageKey,
queryParamKeys: { Account: string; Property: string; Stream: string },
autoFill: boolean = false,
// TODO - This is only here because there seems to be a bug with
// use-query-params replaceIn functionality where it also removes the anchor.
// Need to do a minimum repro and file a bug to that repo.
@@ -38,7 +39,7 @@ const useAccountProperty = (

const [
account,
setAccountID,
setAccountIDLocal,
] = useKeyedHydratedPersistantObject<AccountSummary>(
`${prefix}-account` as StorageKey,
queryParamKeys.Account,
@@ -70,6 +71,25 @@ const useAccountProperty = (
{ keepParam }
)

const setAccountID: Dispatch<string | undefined> = useCallback(
v => {
setAccountIDLocal(old => {
let nu: string | undefined
if (typeof v === "function") {
nu = v(old)
} else {
nu = v
}
if (autoFill) {
const nuAccount = getAccountByID(nu)
setPropertyID(nuAccount?.propertySummaries?.[0]?.property)
}
return nu
})
},
[autoFill, setAccountIDLocal, getAccountByID]
)

return {
account,
setAccountID,
50 changes: 34 additions & 16 deletions src/components/ga4/StreamPicker/useAccountPropertyStream.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StorageKey } from "@/constants"

import { useKeyedHydratedPersistantObject } from "@/hooks/useHydrated"
import { Dispatch, Requestable, successful } from "@/types"
import { PropertySummary, Stream } from "@/types/ga4/StreamPicker"
@@ -16,12 +17,17 @@ export interface AccountPropertyStream extends AccountProperty {

interface AccountPropertyStreamSetters extends AccountPropertySetters {
setStreamID: Dispatch<string | undefined>
updateToFirstStream: () => void
}

const useAccountPropertyStream = (
prefix: StorageKey,
queryParamKeys: { Account: string; Property: string; Stream: string },
streams: {
androidStreams?: boolean
webStreams?: boolean
iosStreams?: boolean
},
autoFill: boolean = false,
// TODO - This is only here because there seems to be a bug with
// use-query-params replaceIn functionality where it also removes the anchor.
// Need to do a minimum repro and file a bug to that repo.
@@ -31,21 +37,14 @@ const useAccountPropertyStream = (
const accountProperty = useAccountProperty(
prefix,
queryParamKeys,
autoFill,
keepParam,
onSetProperty
)

const { property } = accountProperty

const updateToFirstStream = useCallback(() => {
// I don't really like this, but I'm not sure how else to get this to
// update correctly.
setTimeout(() => {
setSetToFirst(true)
}, 100)
}, [])

const streamsRequest = useStreams(property)
const streamsRequest = useStreams(property, streams)

const getStreamsByID = useCallback(
(id: string | undefined) => {
@@ -68,20 +67,39 @@ const useAccountPropertyStream = (
{ keepParam }
)

const [setToFirst, setSetToFirst] = useState(false)
// This seems like a hacky workaround, but I'm not sure what else the pattern
// would be here.
const [needsUpdate, setNeedsUpdate] = useState(false)
useEffect(() => {
if (property === undefined) {
setNeedsUpdate(false)
setStreamID(undefined)
} else {
setNeedsUpdate(true)
}
}, [property])

useEffect(() => {
if (successful(streamsRequest) && setToFirst) {
setStreamID(successful(streamsRequest)?.streams?.[0].value.name)
setSetToFirst(false)
if (autoFill) {
if (successful(streamsRequest) && needsUpdate) {
console.log("updating stream to first from list", {
autoFill,
streamsRequest,
setStreamID,
})
const firstStreamID = successful(streamsRequest)!.streams?.[0]?.value
?.name
setStreamID(firstStreamID)
setNeedsUpdate(false)
}
}
}, [streamsRequest, setToFirst, setStreamID])
}, [autoFill, streamsRequest, setStreamID])

return {
...accountProperty,
stream,
setStreamID,
streamsRequest,
updateToFirstStream,
}
}

3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@ export enum Url {
measurementProtocol = "https://developers.google.com/analytics/devguides/collection/protocol/v1",
validatingMeasurement = "https://developers.google.com/analytics/devguides/collection/protocol/v1/validating-hits",
coreReportingApi = "https://developers.google.com/analytics/devguides/reporting/core/v3/",
ga4MPAPISecretReference = "https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference#api_secret",
}

export enum GAVersion {
@@ -248,6 +249,8 @@ export enum StorageKey {
ga4EventBuilderItems = "ga4/event-builder/items",
ga4EventBuilderEventName = "ga4/event-builder/event-name",
ga4EventBuilderUserProperties = "ga4/event-builder/user-properties",
eventBuilderMPSecrets = "ga4/event-builder/mp-secrets",
eventBuilderAPS = "ga4/event-builder/aps",
}

export const EventAction = {
37 changes: 36 additions & 1 deletion src/hooks/useHydrated.spec.ts → src/hooks/useHydrated.spec.tsx
Original file line number Diff line number Diff line change
@@ -2,8 +2,12 @@ import "@testing-library/jest-dom"
import { renderHook } from "@testing-library/react-hooks"

import { TestWrapper, wrapperFor } from "@/test-utils"
import { useHydratedPersistantString } from "./useHydrated"
import {
useHydratedPersistantString,
useKeyedHydratedPersistantObject,
} from "./useHydrated"
import { StorageKey } from "@/constants"
import { useCallback } from "react"

describe("useHydratedPersistantString", () => {
// The specific storage key shouldn't matter.
@@ -44,3 +48,34 @@ describe("useHydratedPersistantString", () => {
expect(result.current[0]).toBe(expected)
})
})

describe("useKeyedHydratedPersistantObject", () => {
test("grabs value from localStorage for first render.", () => {
const key = "a" as StorageKey
const id = "my-id"
const expectedValue = "abcdef"
const paramName = "paramName"
window.localStorage.setItem(key, JSON.stringify({ value: id }))
const complexValue = { id: "my-id", value: expectedValue }
const { result } = renderHook(
() => {
const getValue = useCallback((key: string | undefined) => {
if (key === id) {
return complexValue
} else {
return undefined
}
}, [])
return useKeyedHydratedPersistantObject<typeof complexValue>(
key,
paramName,
getValue
)
},
{
wrapper: wrapperFor({}),
}
)
expect(result.current[0]?.value).toEqual(expectedValue)
})
})
2 changes: 1 addition & 1 deletion src/hooks/useHydrated.ts
Original file line number Diff line number Diff line change
@@ -85,7 +85,7 @@ export const useKeyedHydratedPersistantObject = <T>(
const setKey: Dispatch<string | undefined> = useCallback(
key => {
setKeyLocal(old => {
let nu: string | undefined = undefined
let nu: string | undefined
if (typeof key === "function") {
nu = key(old)
} else {