diff --git a/src/components/ga4/EventBuilder/MPSecret/index.tsx b/src/components/ga4/EventBuilder/MPSecret/index.tsx new file mode 100644 index 000000000..eb52cc288 --- /dev/null +++ b/src/components/ga4/EventBuilder/MPSecret/index.tsx @@ -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 + secret: MPSecretT | undefined + useFirebase: boolean +} + +const api_secret_reference = ( + api_secret +) + +const MPSecret: React.FC = ({ 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() + + const { + displayName, + setDisplayName, + creationStatus, + setCreationStatus, + } = useInputs() + + return ( +
+ Choose an account, property, and stream. + + + Select an existing api_secret or create a new secret. + + + The API secret for the property to send the event to. See{" "} + {api_secret_reference} on devsite + + } + > +
+ + 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 => ( + + )} + renderInput={params => ( + + )} + /> +
+ { + setCreationStatus(CreationStatus.ShowDialog) + }} + > + new secret + +
+ + setCreationStatus(CreationStatus.NotStarted)} + > + Create new secret +
+ {creationStatus === CreationStatus.ShowDialog ? ( + setDisplayName(e.target.value)} + /> + ) : ( + creating new secret + )} +
+ { + setCreationStatus(CreationStatus.Creating) + try { + const nuSecret = await successful( + secretsRequest + )!.createMPSecret(displayName) + setCreationStatus(CreationStatus.Done) + setSecret(nuSecret) + } catch (e) { + setCreationError(e) + setCreationStatus(CreationStatus.Failed) + } + }} + > + Create + +
+
+
+
+ {creationError && {creationError?.message}} +
+
+ ) +} + +export default MPSecret diff --git a/src/components/ga4/EventBuilder/MPSecret/useCreateMPSecret.ts b/src/components/ga4/EventBuilder/MPSecret/useCreateMPSecret.ts new file mode 100644 index 000000000..a5b2526d3 --- /dev/null +++ b/src/components/ga4/EventBuilder/MPSecret/useCreateMPSecret.ts @@ -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 diff --git a/src/components/ga4/EventBuilder/MPSecret/useGetMPSecrets.ts b/src/components/ga4/EventBuilder/MPSecret/useGetMPSecrets.ts new file mode 100644 index 000000000..26b3df652 --- /dev/null +++ b/src/components/ga4/EventBuilder/MPSecret/useGetMPSecrets.ts @@ -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 diff --git a/src/components/ga4/EventBuilder/MPSecret/useInputs.ts b/src/components/ga4/EventBuilder/MPSecret/useInputs.ts new file mode 100644 index 000000000..45691c583 --- /dev/null +++ b/src/components/ga4/EventBuilder/MPSecret/useInputs.ts @@ -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.NotStarted + ) + + return { + displayName, + setDisplayName, + creationStatus, + setCreationStatus, + } +} + +export default useInputs diff --git a/src/components/ga4/EventBuilder/MPSecret/useMPSecretsRequest.ts b/src/components/ga4/EventBuilder/MPSecret/useMPSecretsRequest.ts new file mode 100644 index 000000000..f9c3b4999 --- /dev/null +++ b/src/components/ga4/EventBuilder/MPSecret/useMPSecretsRequest.ts @@ -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 +} + +export interface MPSecret { + displayName?: string + name?: string + secretValue: string +} + +interface Args { + stream: Stream | undefined +} +const useMPSecretsRequest = ({ stream }: Args): Requestable => { + 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 diff --git a/src/components/ga4/EventBuilder/ValidateEvent/useSharableLink.ts b/src/components/ga4/EventBuilder/ValidateEvent/useSharableLink.ts index dd2a63aba..46de33449 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/useSharableLink.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/useSharableLink.ts @@ -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() diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 2a0125d15..8f465d3d5 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -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 = () => {
+ = { 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,7 +120,7 @@ export const ParametersParam: QueryParamConfig< const useEvent = (initial?: EventType) => { const [typeString, setTypeLocal] = useHydratedPersistantString( StorageKey.ga4EventBuilderLastEventType, - UrlParam.EventType, + QueryParam.EventType, initial || EventType.SelectContent ) @@ -128,21 +128,21 @@ const useEvent = (initial?: EventType) => { 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 ) diff --git a/src/components/ga4/EventBuilder/useInputs.ts b/src/components/ga4/EventBuilder/useInputs.ts index f3049480e..42face5b8 100644 --- a/src/components/ga4/EventBuilder/useInputs.ts +++ b/src/components/ga4/EventBuilder/useInputs.ts @@ -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() + return { useFirebase, setUseFirebase, @@ -80,6 +83,8 @@ const useInputs = (categories: Category[]) => { setNonPersonalizedAds, timestamp_micros, setTimestampMicros, + secret, + setSecret, } } diff --git a/src/components/ga4/EventBuilder/useUserProperties.ts b/src/components/ga4/EventBuilder/useUserProperties.ts index a74e77a47..412251e85 100644 --- a/src/components/ga4/EventBuilder/useUserProperties.ts +++ b/src/components/ga4/EventBuilder/useUserProperties.ts @@ -3,7 +3,7 @@ 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 = () => { @@ -11,7 +11,7 @@ const useUserProperties = () => { Parameter[] >( StorageKey.ga4EventBuilderUserProperties, - UrlParam.UserProperties, + QueryParam.UserProperties, ParametersParam ) diff --git a/src/components/ga4/StreamPicker/index.tsx b/src/components/ga4/StreamPicker/index.tsx index 2f751a33b..b1e533147 100644 --- a/src/components/ga4/StreamPicker/index.tsx +++ b/src/components/ga4/StreamPicker/index.tsx @@ -29,26 +29,24 @@ interface CommonProps { property: PropertySummary | undefined setAccountID: Dispatch setPropertyID: Dispatch - 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 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 = 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 = 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 => ( = props => { onChange={(_event, value) => { const property = value === null ? undefined : value setPropertyID(property?.property) - - if (autoFill) { - props.streams && props.updateToFirstStream() - } }} renderOption={summary => ( = 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!} diff --git a/src/components/ga4/StreamPicker/useAccountProperty.spec.ts b/src/components/ga4/StreamPicker/useAccountProperty.spec.ts new file mode 100644 index 000000000..1518e2c9f --- /dev/null +++ b/src/components/ga4/StreamPicker/useAccountProperty.spec.ts @@ -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 + // >(() => + // 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) + // }) + // }) + // }) + // }) +}) diff --git a/src/components/ga4/StreamPicker/useAccountProperty.ts b/src/components/ga4/StreamPicker/useAccountProperty.ts index 5ad09b615..a93488b51 100644 --- a/src/components/ga4/StreamPicker/useAccountProperty.ts +++ b/src/components/ga4/StreamPicker/useAccountProperty.ts @@ -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( `${prefix}-account` as StorageKey, queryParamKeys.Account, @@ -70,6 +71,25 @@ const useAccountProperty = ( { keepParam } ) + const setAccountID: Dispatch = 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, diff --git a/src/components/ga4/StreamPicker/useAccountPropertyStream.ts b/src/components/ga4/StreamPicker/useAccountPropertyStream.ts index e2c4cb41e..ba70d2333 100644 --- a/src/components/ga4/StreamPicker/useAccountPropertyStream.ts +++ b/src/components/ga4/StreamPicker/useAccountPropertyStream.ts @@ -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 - 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, } } diff --git a/src/constants.ts b/src/constants.ts index 03c1e02af..3518e84d9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 = { diff --git a/src/hooks/useHydrated.spec.ts b/src/hooks/useHydrated.spec.tsx similarity index 54% rename from src/hooks/useHydrated.spec.ts rename to src/hooks/useHydrated.spec.tsx index ea640428c..25c5a02a4 100644 --- a/src/hooks/useHydrated.spec.ts +++ b/src/hooks/useHydrated.spec.tsx @@ -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( + key, + paramName, + getValue + ) + }, + { + wrapper: wrapperFor({}), + } + ) + expect(result.current[0]?.value).toEqual(expectedValue) + }) +}) diff --git a/src/hooks/useHydrated.ts b/src/hooks/useHydrated.ts index 7fdca88f3..dc41b64ee 100644 --- a/src/hooks/useHydrated.ts +++ b/src/hooks/useHydrated.ts @@ -85,7 +85,7 @@ export const useKeyedHydratedPersistantObject = ( const setKey: Dispatch = useCallback( key => { setKeyLocal(old => { - let nu: string | undefined = undefined + let nu: string | undefined if (typeof key === "function") { nu = key(old) } else {