diff --git a/src/components/ga4/EventBuilder/GeographicInformation.tsx b/src/components/ga4/EventBuilder/GeographicInformation.tsx new file mode 100644 index 00000000..4cb03455 --- /dev/null +++ b/src/components/ga4/EventBuilder/GeographicInformation.tsx @@ -0,0 +1,138 @@ +import React from "react" +import Chip from "@mui/material/Chip"; +import Divider from '@mui/material/Divider'; +import { styled } from "@mui/material/styles" +import Typography from "@mui/material/Typography" +import TextField from "@mui/material/TextField" +import Grid from "@mui/material/Grid" + +import LinkedTextField from "@/components/LinkedTextField" +import ExternalLink from "@/components/ExternalLink" +import { Label } from "./types" + +const Root = styled("div")(({ theme }) => ({ + marginTop: theme.spacing(3), +})) + +interface GeographicInformationProps { + user_location_city: string | undefined + setUserLocationCity: (value: string) => void + user_location_region_id: string | undefined + setUserLocationRegionId: (value: string) => void + user_location_country_id: string | undefined + setUserLocationCountryId: (value: string) => void + user_location_subcontinent_id: string | undefined + setUserLocationSubcontinentId: (value: string) => void + user_location_continent_id: string | undefined + setUserLocationContinentId: (value: string) => void + ip_override: string | undefined + setIpOverride: (value: string) => void +} + +const GeographicInformation: React.FC = ({ + user_location_city, + setUserLocationCity, + user_location_region_id, + setUserLocationRegionId, + user_location_country_id, + setUserLocationCountryId, + user_location_subcontinent_id, + setUserLocationSubcontinentId, + user_location_continent_id, + setUserLocationContinentId, + ip_override, + setIpOverride, +}) => { + return ( + + + User Location + + See the{" "} + + documentation + {" "} + for more information about user location attributes. + + + + setUserLocationCity(e.target.value)} + helperText="The city name, e.g., Mountain View" + /> + + + setUserLocationRegionId(e.target.value)} + helperText="The country and subdivision, e.g., US-CA" + /> + + + setUserLocationCountryId(e.target.value)} + helperText="The country code, e.g., US" + /> + + + setUserLocationContinentId(e.target.value)} + helperText="The continent code, e.g., 019" + /> + + + setUserLocationSubcontinentId(e.target.value)} + helperText="The subcontinent code, e.g., 021" + /> + + + IP Override + + Provide an IP address to derive the user's geographic location. If + both an IP override and user location are provided, user location will + be used. + + + + ) +} + +export default GeographicInformation diff --git a/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx index 0ed90e1a..7df63e0b 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx +++ b/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx @@ -53,6 +53,14 @@ const renderComponent = (props: Partial = {}) => { payloadObj: [], api_secret: "secret123", clientIds: {}, + ip_override: "", + user_location: { + city: "Mountain View", + region_id: "CA", + country_id: "US", + subcontinent_id: "021", + continent_id: "019" + } } return render( diff --git a/src/components/ga4/EventBuilder/ValidateEvent/index.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.tsx index 4bff5375..0154dc47 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/index.tsx +++ b/src/components/ga4/EventBuilder/ValidateEvent/index.tsx @@ -29,7 +29,8 @@ import PrettyJson from "@/components/PrettyJson" import usePayload from "./usePayload" import { ValidationMessage } from "../types" import Spinner from "@/components/Spinner" -import { EventCtx, Label } from ".." +import { EventCtx } from ".." +import { Label } from "../types" import { Box, Card } from "@mui/material" import { green, red } from "@mui/material/colors" import WithHelpText from "@/components/WithHelpText" diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts index d34705f8..68e5b7e2 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts @@ -142,4 +142,16 @@ describe("baseContentSchema", () => { expect(validator.isValid(validInput)).toEqual(false) }) + + describe("with ip_override", () => { + test("is valid with a valid IPv4 address", () => { + const validInput = { + events: [{ name: "something", params: {} }], + ip_override: "127.0.0.1", + } + const validator = new Validator(baseContentSchema) + expect(validator.isValid(validInput)).toEqual(true) + }) + }) + }) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts index 303834fb..0245e26b 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts @@ -2,29 +2,34 @@ import { userPropertiesSchema } from './userProperties' import { eventsSchema } from './events' +import { userLocationSchema } from "./userLocation" export const baseContentSchema = { - "type": "object", - "required": ["events"], - "additionalProperties": false, - "properties": { - "app_instance_id": { - "type": "string", - "format": "app_instance_id" - }, - "client_id": { - "type": "string", - }, - "user_id": { - "type": "string" - }, - "timestamp_micros": { - // "type": "number" - }, - "user_properties": userPropertiesSchema, - "non_personalized_ads": { - "type": "boolean" - }, - "events": eventsSchema, - } + type: "object", + required: ["events"], + additionalProperties: false, + properties: { + app_instance_id: { + type: "string", + format: "app_instance_id", + }, + client_id: { + type: "string", + }, + user_id: { + type: "string", + }, + timestamp_micros: { + // "type": "number" + }, + user_properties: userPropertiesSchema, + non_personalized_ads: { + type: "boolean", + }, + events: eventsSchema, + user_location: userLocationSchema, + ip_override: { + type: "string", + }, + }, } \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/userLocation.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userLocation.ts new file mode 100644 index 00000000..799db137 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userLocation.ts @@ -0,0 +1,23 @@ +// User location schema + +export const userLocationSchema = { + type: "object", + additionalProperties: false, + properties: { + city: { + type: "string", + }, + region_id: { + type: "string", + }, + country_id: { + type: "string", + }, + subcontinent_id: { + type: "string", + }, + continent_id: { + type: "string", + }, + }, +} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts index 6c99e1f8..854cf155 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts @@ -71,7 +71,9 @@ const usePayload = (): {} => { clientIds, type, useTextBox, - payloadObj + payloadObj, + ip_override, + user_location, } = useContext(EventCtx)! const eventName = useMemo(() => { @@ -93,22 +95,34 @@ const usePayload = (): {} => { [items] ) - const params = useMemo(() => parameters.reduce(objectify, itemsParameter), [ - parameters, - itemsParameter, - ]) + const params = useMemo( + () => parameters.reduce(objectify, itemsParameter), + [parameters, itemsParameter] + ) const user_properties = useMemo( () => userProperties.reduce(objectifyUserProperties, {}), [userProperties] ) + const user_location_info = useMemo(() => { + if (user_location === undefined) { + return undefined + } + const cleaned_location = removeUndefined(user_location) + if (Object.keys(cleaned_location).length === 0) { + return undefined + } + return cleaned_location + }, [user_location]) + let payload = useMemo(() => { return { ...removeUndefined(clientIds), ...removeUndefined({ timestamp_micros }), ...removeUndefined({ non_personalized_ads }), ...removeUndefined(removeEmptyObject({ user_properties })), + ...removeUndefined({ ip_override, user_location: user_location_info }), events: [ { name: eventName, ...(parameters.length > 0 ? { params } : {}) }, ], @@ -121,6 +135,8 @@ const usePayload = (): {} => { params, timestamp_micros, user_properties, + ip_override, + user_location_info, ]) if (useTextBox) { diff --git a/src/components/ga4/EventBuilder/index.spec.tsx b/src/components/ga4/EventBuilder/index.spec.tsx index f856c824..34362a0a 100644 --- a/src/components/ga4/EventBuilder/index.spec.tsx +++ b/src/components/ga4/EventBuilder/index.spec.tsx @@ -18,7 +18,8 @@ import * as renderer from "@testing-library/react" import "@testing-library/jest-dom" import { withProviders } from "@/test-utils" -import Sut, { Label } from "./index" +import Sut from "./index" +import { Label } from "./types" import userEvent from "@testing-library/user-event" import { within } from "@testing-library/react" diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index dfe3a04f..b4550205 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -36,13 +36,14 @@ import { TooltipIconButton } from "@/components/Buttons" import useEvent from "./useEvent" import Parameters from "./Parameters" import useInputs from "./useInputs" -import { Category, ClientIds, EventType, InstanceId, Parameter } from "./types" +import { Category, ClientIds, EventType, InstanceId, Parameter, Label } from "./types" import { eventsForCategory } from "./event" import useUserProperties from "./useUserProperties" import Items from "./Items" import ValidateEvent from "./ValidateEvent" import { PlainButton } from "@/components/Buttons" import { useEffect } from "react" +import GeographicInformation from "./GeographicInformation"; const PREFIX = 'EventBuilder'; @@ -103,35 +104,6 @@ const Root = styled('div')(( } })); -export enum Label { - APISecret = "api secret", - - FirebaseAppID = "firebase app id", - AppInstanceID = "app instance id", - - MeasurementID = "measurement id", - ClientID = "client id", - - UserId = "user id", - - EventCategory = "event category", - EventName = "event name", - TimestampMicros = "timestamp micros", - NonPersonalizedAds = "non personalized ads", - - Payload = "payload", - - // event params - Coupon = '#/events/0/params/coupon', - Currency = '#/events/0/params/currency', - Value = '#/events/0/params/value', - ItemId = '#/events/0/params/item_id', - TransactionId = '#/events/0/params/transaction_id', - Affiliation = '#/events/0/params/affiliation', - Shipping = '#/events/0/params/shipping', - Tax = '#/events/0/params/tax', -} - const ga4MeasurementProtocol = ( GA4 Measurement Protocol @@ -151,6 +123,14 @@ export type EventPayload = { api_secret: string useTextBox: boolean payloadObj: any + ip_override: string | undefined + user_location: { + city: string | undefined + region_id: string | undefined + country_id: string | undefined + subcontinent_id: string | undefined + continent_id: string | undefined + } } export const EventCtx = React.createContext< | EventPayload @@ -230,18 +210,23 @@ const EventBuilder: React.FC = () => { [category, useFirebase] ) - const formatPayload = React.useCallback( () => { + const [user_location_city, setUserLocationCity] = React.useState("") + const [user_location_region_id, setUserLocationRegionId] = React.useState("") + const [user_location_country_id, setUserLocationCountryId] = React.useState("") + const [user_location_subcontinent_id, setUserLocationSubcontinentId] = React.useState("") + const [user_location_continent_id, setUserLocationContinentId] = React.useState("") + const [ip_override, setIpOverride] = React.useState("") + + const formatPayload = React.useCallback(() => { try { if (inputPayload) { let payload = JSON.parse(inputPayload) as object - setPayloadObj(JSON.stringify(payload, null, '\t')) - setPayloadErrors('') - } - else { - setPayloadErrors('Empty Payload') + setPayloadObj(JSON.stringify(payload, null, "\t")) + setPayloadErrors("") + } else { + setPayloadErrors("Empty Payload") setPayloadObj({}) } - } catch (err: any) { setPayloadErrors(err.message) setPayloadObj({}) @@ -571,7 +556,8 @@ const EventBuilder: React.FC = () => { Finally, specify the parameters to send with the event. By default, only recommended parameters for the event will appear here. Check "show - advanced options" to add custom parameters or user properties. + advanced options" to add custom parameters, user properties, or geographic + information. show advanced options @@ -607,24 +593,47 @@ const EventBuilder: React.FC = () => { /> )} - {(showAdvanced || - (userProperties !== undefined && userProperties.length !== 0)) && ( - <> - User properties - - - )} - - - - } + {(showAdvanced || + (userProperties !== undefined && + userProperties.length !== 0)) && ( + <> + User properties + + + )} + {showAdvanced && ( + <> + + + )} + + + + } Validate & Send event @@ -645,6 +654,14 @@ const EventBuilder: React.FC = () => { payloadObj, instanceId: useFirebase ? { firebase_app_id } : { measurement_id }, api_secret: api_secret!, + ip_override, + user_location: { + city: user_location_city, + region_id: user_location_region_id, + country_id: user_location_country_id, + subcontinent_id: user_location_subcontinent_id, + continent_id: user_location_continent_id, + }, }} > { - ); + ) } export default EventBuilder diff --git a/src/components/ga4/EventBuilder/types.ts b/src/components/ga4/EventBuilder/types.ts index f8d108ea..763ac785 100644 --- a/src/components/ga4/EventBuilder/types.ts +++ b/src/components/ga4/EventBuilder/types.ts @@ -84,6 +84,43 @@ export interface Event2 { items?: Parameter[][] } +export enum Label { + APISecret = "api secret", + + FirebaseAppID = "firebase app id", + AppInstanceID = "app instance id", + + MeasurementID = "measurement id", + ClientID = "client id", + + UserId = "user id", + + EventCategory = "event category", + EventName = "event name", + TimestampMicros = "timestamp micros", + NonPersonalizedAds = "non personalized ads", + + Payload = "payload", + + // event params + Coupon = "#/events/0/params/coupon", + Currency = "#/events/0/params/currency", + Value = "#/events/0/params/value", + ItemId = "#/events/0/params/item_id", + TransactionId = "#/events/0/params/transaction_id", + Affiliation = "#/events/0/params/affiliation", + Shipping = "#/events/0/params/shipping", + Tax = "#/events/0/params/tax", + + // Geographic Information + IpOverride = "ip address", + City = "city", + RegionId = "region id", + CountryId = "country id", + SubcontinentId = "subcontinent id", + ContinentId = "continent id", +} + // TODO - Add test to ensure url param values are all unique. export enum UrlParam { Parameters = "a",