Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading