From 7cae7ef95ce6c6eda3a58a6560c0d97189efa2c2 Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Fri, 12 Sep 2025 14:44:55 +0000 Subject: [PATCH 01/12] add timestamp_micros to user properties --- src/components/ga4/EventBuilder/Items.tsx | 2 + src/components/ga4/EventBuilder/Parameter.tsx | 119 +++++++++--------- .../ga4/EventBuilder/Parameters.tsx | 6 + .../ValidateEvent/schemas/userProperties.ts | 6 +- .../EventBuilder/ValidateEvent/usePayload.ts | 18 +-- src/components/ga4/EventBuilder/index.tsx | 5 + src/components/ga4/EventBuilder/types.ts | 2 + .../ga4/EventBuilder/useUserProperties.ts | 8 ++ 8 files changed, 98 insertions(+), 68 deletions(-) diff --git a/src/components/ga4/EventBuilder/Items.tsx b/src/components/ga4/EventBuilder/Items.tsx index 7b5251871..1d6e0606f 100644 --- a/src/components/ga4/EventBuilder/Items.tsx +++ b/src/components/ga4/EventBuilder/Items.tsx @@ -82,6 +82,8 @@ const Items: React.FC = ({ addNumberParam={() => addItemNumberParam(idx)} removeParam={(itemIdx: number) => removeItemParam(idx, itemIdx)} removeItem={() => removeItem(idx)} + setParamTimestamp={() => {}} + isUserProperty={false} /> ))} diff --git a/src/components/ga4/EventBuilder/Parameter.tsx b/src/components/ga4/EventBuilder/Parameter.tsx index 5e1509780..86ec562ad 100644 --- a/src/components/ga4/EventBuilder/Parameter.tsx +++ b/src/components/ga4/EventBuilder/Parameter.tsx @@ -1,88 +1,95 @@ import * as React from "react" -import { styled } from '@mui/material/styles'; import TextField from "@mui/material/TextField" import { Parameter as ParameterT } from "./types" import { ShowAdvancedCtx } from "." -import { IconButton, Tooltip } from "@mui/material" +import { IconButton, Tooltip, Grid } from "@mui/material" import { Delete } from "@mui/icons-material" -const PREFIX = 'Parameter'; - -const classes = { - parameter: `${PREFIX}-parameter` -}; - -const Root = styled('section')(( - { - theme - } -) => ({ - [`&.${classes.parameter}`]: { - display: "flex", - "&> *": { - flexGrow: 1, - }, - "&> :not(:first-child)": { - marginLeft: theme.spacing(1), - }, - } -})); - interface Props { parameter: ParameterT setParamName: (name: string) => void setParamValue: (value: string) => void + setParamTimestamp: (value: number) => void removeParam: () => void + isUserProperty: boolean } const Parameter: React.FC = ({ parameter, setParamName, setParamValue, + setParamTimestamp, removeParam, + isUserProperty, }) => { - const showAdvanced = React.useContext(ShowAdvancedCtx) const [name, setName] = React.useState(parameter.name) const [value, setValue] = React.useState(parameter.value || "") + const [timestamp, setTimestamp] = React.useState( + parameter.timestamp_micros?.toString() || "" + ) const inputs = ( - - setName(e.target.value)} - onBlur={() => setParamName(name)} - label="name" - /> - setValue(e.target.value)} - onBlur={() => setParamValue(value)} - label={`${parameter.type} value`} - placeholder={parameter.exampleValue?.toString()} - /> - + + + setName(e.target.value)} + onBlur={() => setParamName(name)} + label="name" + fullWidth + /> + + + setValue(e.target.value)} + onBlur={() => setParamValue(value)} + label={`${parameter.type} value`} + placeholder={parameter.exampleValue?.toString()} + fullWidth + /> + + {isUserProperty && ( + + setTimestamp(e.target.value)} + onBlur={() => setParamTimestamp(parseInt(timestamp, 10))} + label="timestamp micros" + helperText="The timestamp to be applied to the user property. Optional." + fullWidth + /> + + )} + ) if (showAdvanced) { return ( -
- - - - - - {inputs} -
+ + + + + + + + + + {inputs} + + ) } return inputs diff --git a/src/components/ga4/EventBuilder/Parameters.tsx b/src/components/ga4/EventBuilder/Parameters.tsx index 26274b614..707a49c33 100644 --- a/src/components/ga4/EventBuilder/Parameters.tsx +++ b/src/components/ga4/EventBuilder/Parameters.tsx @@ -35,22 +35,26 @@ interface Props { parameters: ParameterT[] setParamName: (idx: number, name: string) => void setParamValue: (idx: number, value: string) => void + setParamTimestamp: (idx: number, value: number) => void addStringParam: () => void addNumberParam: () => void removeParam: (idx: number) => void removeItem?: () => void addItemsParam?: () => void + isUserProperty: boolean } const Parameters: React.FC = ({ parameters, setParamName, setParamValue, + setParamTimestamp, addStringParam, addNumberParam, removeParam, addItemsParam, removeItem, + isUserProperty, }) => { const showAdvanced = React.useContext(ShowAdvancedCtx) @@ -62,7 +66,9 @@ const Parameters: React.FC = ({ parameter={parameter} setParamName={name => setParamName(idx, name)} setParamValue={value => setParamValue(idx, value)} + setParamTimestamp={timestamp => setParamTimestamp(idx, timestamp)} removeParam={() => removeParam(idx)} + isUserProperty={isUserProperty} /> ))}
diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts index 7101a5279..7ddb0be53 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts @@ -8,15 +8,11 @@ export const userPropertiesSchema = { "type": "object", "required": ["value"], "additionalProperties": false, - "properties": { "value": { "maxLength": 36 }, - "timestamp_micros": { - "type": "number", - "maxLength": 36 - } + "timestamp_micros": {} } } }, diff --git a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts index 6c99e1f80..4880e8e67 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts @@ -28,15 +28,19 @@ const objectifyUserProperties = (acc: {}, p: Parameter) => { if (p.type === ParameterType.Number) { value = tryParseNum(value) } + + if (p.name === "" || value === "" || value === undefined) { + return acc + } + + const newProp: { value: any; timestamp_micros?: number } = { value } + if (p.timestamp_micros) { + newProp.timestamp_micros = p.timestamp_micros + } + return { ...acc, - ...(p.name !== "" && value !== "" && value !== undefined - ? { - [p.name]: { - value, - }, - } - : {}), + [p.name]: newProp, } } diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 34fed9eaa..26977304c 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -169,6 +169,7 @@ const EventBuilder: React.FC = () => { removeUserProperty, setUserPropertyName, setUserPropertyValue, + setUserPropertyTimestamp, } = useUserProperties() const { @@ -591,6 +592,8 @@ const EventBuilder: React.FC = () => { setParamName={setParamName} setParamValue={setParamValue} addItemsParam={items === undefined ? addItemsParam : undefined} + setParamTimestamp={() => {}} + isUserProperty={false} /> {items !== undefined && ( <> @@ -619,6 +622,8 @@ const EventBuilder: React.FC = () => { addNumberParam={addNumberUserProperty} setParamName={setUserPropertyName} setParamValue={setUserPropertyValue} + setParamTimestamp={setUserPropertyTimestamp} + isUserProperty={true} /> )} diff --git a/src/components/ga4/EventBuilder/types.ts b/src/components/ga4/EventBuilder/types.ts index f8d108ea8..c2c04f63c 100644 --- a/src/components/ga4/EventBuilder/types.ts +++ b/src/components/ga4/EventBuilder/types.ts @@ -67,12 +67,14 @@ export interface NumberParameter { name: string value: string | undefined exampleValue?: number + timestamp_micros?: number } export interface StringParameter { type: ParameterType.String name: string value: string | undefined exampleValue?: string + timestamp_micros?: number } export type Parameter = NumberParameter | StringParameter diff --git a/src/components/ga4/EventBuilder/useUserProperties.ts b/src/components/ga4/EventBuilder/useUserProperties.ts index ce44f9344..072b5bc9c 100644 --- a/src/components/ga4/EventBuilder/useUserProperties.ts +++ b/src/components/ga4/EventBuilder/useUserProperties.ts @@ -43,6 +43,13 @@ const useUserProperties = () => { [updateUserProperty] ) + const setUserPropertyTimestamp = useCallback( + (idx: number, timestamp_micros: number) => { + updateUserProperty(idx, old => ({ ...old, timestamp_micros })) + }, + [updateUserProperty] + ) + return { userProperties: userProperties || [], addStringUserProperty, @@ -50,6 +57,7 @@ const useUserProperties = () => { removeUserProperty, setUserPropertyValue, setUserPropertyName, + setUserPropertyTimestamp, } } From 5cee808e05d7d67255f8dafecff0ac3a87a4275c Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Fri, 12 Sep 2025 15:47:16 +0000 Subject: [PATCH 02/12] update style and include event update --- src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts | 3 ++- src/components/ga4/EventBuilder/index.tsx | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts index db7876acf..39a477537 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts @@ -14,7 +14,8 @@ export const eventSchema = { "maxLength": 40 }, "params": {"type": "object"}, - "items": itemsSchema + "items": itemsSchema, + "timestamp_micros": {} }, "allOf": buildEvents() } diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 26977304c..20d64f6f8 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -91,6 +91,9 @@ const Root = styled('div')(( [`& .${classes.form}`]: { maxWidth: "80ch", + "& h5:not(:first-of-type)": { + marginTop: theme.spacing(4), + }, }, [`& .${classes.items}`]: { From 1a013b0f2ce9bbc030496b1e05193c99ce3541a3 Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Fri, 12 Sep 2025 16:16:28 +0000 Subject: [PATCH 03/12] fix request level timestamp_micros not being sent as number --- src/components/ga4/EventBuilder/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 20d64f6f8..f3ffb79ef 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -147,7 +147,7 @@ export type EventPayload = { parameters: Parameter[] items: Parameter[][] | undefined userProperties: Parameter[] - timestamp_micros: string | undefined + timestamp_micros: number | undefined non_personalized_ads: boolean | undefined clientIds: ClientIds instanceId: InstanceId @@ -648,7 +648,7 @@ const EventBuilder: React.FC = () => { parameters, eventName, userProperties, - timestamp_micros, + timestamp_micros: (num => isNaN(num) ? undefined : num)(parseFloat(timestamp_micros || '')), non_personalized_ads, useTextBox, payloadObj, From 6a2870ea847d37f302902c9eccdce0abe07f8f0d Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Fri, 12 Sep 2025 16:24:58 +0000 Subject: [PATCH 04/12] fix tests --- src/components/ga4/EventBuilder/index.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ga4/EventBuilder/index.spec.tsx b/src/components/ga4/EventBuilder/index.spec.tsx index f856c8242..3976167b7 100644 --- a/src/components/ga4/EventBuilder/index.spec.tsx +++ b/src/components/ga4/EventBuilder/index.spec.tsx @@ -130,7 +130,7 @@ describe("Event Builder", () => { /"app_instance_id":"my_instance_id"/ ) expect(payload).toHaveTextContent(/"user_id":"my_user_id"/) - expect(payload).toHaveTextContent(/"timestamp_micros":"1234"/) + expect(payload).toHaveTextContent(/"timestamp_micros":1234/) expect(payload).toHaveTextContent(/"non_personalized_ads":true/) expect(payload).toHaveTextContent(/"name":"select_content"/) }) @@ -206,7 +206,7 @@ describe("Event Builder", () => { const payload = await findByTestId("payload") expect(payload).toHaveTextContent(/"client_id":"my_client_id"/) expect(payload).toHaveTextContent(/"user_id":"my_user_id"/) - expect(payload).toHaveTextContent(/"timestamp_micros":"1234"/) + expect(payload).toHaveTextContent(/"timestamp_micros":1234/) expect(payload).toHaveTextContent(/"non_personalized_ads":true/) expect(payload).toHaveTextContent(/"name":"campaign_details"/) }) From 1ec650ad2100055a23159237140acf2cb290145b Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Mon, 15 Sep 2025 20:05:43 +0000 Subject: [PATCH 05/12] add timestamp picker component --- src/components/ExternalLink.tsx | 46 ++-- src/components/LinkedTextField.tsx | 10 +- src/components/ga4/EventBuilder/Parameter.tsx | 38 ++-- .../ga4/EventBuilder/Parameters.tsx | 5 +- .../ga4/EventBuilder/TimestampPicker.spec.tsx | 201 ++++++++++++++++++ .../ga4/EventBuilder/TimestampPicker.tsx | 187 ++++++++++++++++ .../ga4/EventBuilder/TimezoneSelect.tsx | 37 ++++ src/components/ga4/EventBuilder/index.tsx | 45 ++-- .../ga4/EventBuilder/useUserProperties.ts | 2 +- src/constants.ts | 5 + 10 files changed, 500 insertions(+), 76 deletions(-) create mode 100644 src/components/ga4/EventBuilder/TimestampPicker.spec.tsx create mode 100644 src/components/ga4/EventBuilder/TimestampPicker.tsx create mode 100644 src/components/ga4/EventBuilder/TimezoneSelect.tsx diff --git a/src/components/ExternalLink.tsx b/src/components/ExternalLink.tsx index ffca8f806..db391b551 100644 --- a/src/components/ExternalLink.tsx +++ b/src/components/ExternalLink.tsx @@ -11,29 +11,25 @@ import { PropsWithChildren } from "react" const PREFIX = "ExternalLink" const classes = { - link: `${PREFIX}-link`, icon: `${PREFIX}-icon`, hover: `${PREFIX}-hover`, } -const Root = styled("span")(() => ({ - [`& .${classes.link}`]: { - display: "inline-flex", - alignItems: "center", - }, - +const StyledLink = styled("a")({ + display: "inline-flex", + alignItems: "center", [`& .${classes.icon}`]: { marginLeft: "0.5ch", color: "inherit", }, - [`& .${classes.hover}`]: { + [`&.${classes.hover}`]: { "&:hover": { opacity: 1.0, }, opacity: 0.3, }, -})) +}) type Props = { href: string @@ -48,23 +44,21 @@ const ExternalLink: React.FC> = ({ hover, }) => { return ( - - - - {children} - - - - + + + {children} + + + ) } diff --git a/src/components/LinkedTextField.tsx b/src/components/LinkedTextField.tsx index 1dde9230c..45d3ccde5 100644 --- a/src/components/LinkedTextField.tsx +++ b/src/components/LinkedTextField.tsx @@ -8,11 +8,14 @@ export interface LinkedTextFieldProps { value: string | undefined label: string onChange: (e: string) => void + onBlur?: () => void helperText: string | JSX.Element extraAction?: JSX.Element required?: true disabled?: boolean id?: string + error?: boolean + size?: "small" | "medium" } const LinkedTextField: React.FC = ({ @@ -21,11 +24,14 @@ const LinkedTextField: React.FC = ({ label, value, onChange, + onBlur, required, helperText, disabled, extraAction, id, + error, + size = "small", }) => { return ( = ({ ), }} id={id} - size="small" + size={size} variant="outlined" fullWidth label={label} value={value === undefined ? "" : value} onChange={e => onChange(e.target.value)} + onBlur={onBlur} required={required} helperText={helperText} disabled={disabled} + error={error} /> ) } diff --git a/src/components/ga4/EventBuilder/Parameter.tsx b/src/components/ga4/EventBuilder/Parameter.tsx index 86ec562ad..9f18958b4 100644 --- a/src/components/ga4/EventBuilder/Parameter.tsx +++ b/src/components/ga4/EventBuilder/Parameter.tsx @@ -3,20 +3,28 @@ import * as React from "react" import TextField from "@mui/material/TextField" import { Parameter as ParameterT } from "./types" import { ShowAdvancedCtx } from "." -import { IconButton, Tooltip, Grid } from "@mui/material" -import { Delete } from "@mui/icons-material" +import { + IconButton, + Tooltip, + Grid +} from "@mui/material" +import { Delete, ExpandMore } from "@mui/icons-material" +import TimestampPicker from "./TimestampPicker" +import { TimestampScope } from "@/constants" interface Props { parameter: ParameterT + idx: number setParamName: (name: string) => void setParamValue: (value: string) => void - setParamTimestamp: (value: number) => void + setParamTimestamp: (idx: number, value: number | undefined) => void removeParam: () => void isUserProperty: boolean } const Parameter: React.FC = ({ parameter, + idx, setParamName, setParamValue, setParamTimestamp, @@ -31,6 +39,11 @@ const Parameter: React.FC = ({ parameter.timestamp_micros?.toString() || "" ) + React.useEffect(() => { + const num = parseInt(timestamp, 10) + setParamTimestamp(idx, isNaN(num) ? undefined : num) + }, [timestamp, setParamTimestamp, idx]) + const inputs = ( @@ -61,17 +74,12 @@ const Parameter: React.FC = ({ /> {isUserProperty && ( - - setTimestamp(e.target.value)} - onBlur={() => setParamTimestamp(parseInt(timestamp, 10))} - label="timestamp micros" - helperText="The timestamp to be applied to the user property. Optional." - fullWidth - /> + + )} @@ -95,4 +103,4 @@ const Parameter: React.FC = ({ return inputs } -export default Parameter +export default Parameter \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/Parameters.tsx b/src/components/ga4/EventBuilder/Parameters.tsx index 707a49c33..c704afa9a 100644 --- a/src/components/ga4/EventBuilder/Parameters.tsx +++ b/src/components/ga4/EventBuilder/Parameters.tsx @@ -35,7 +35,7 @@ interface Props { parameters: ParameterT[] setParamName: (idx: number, name: string) => void setParamValue: (idx: number, value: string) => void - setParamTimestamp: (idx: number, value: number) => void + setParamTimestamp: (idx: number, value: number | undefined) => void addStringParam: () => void addNumberParam: () => void removeParam: (idx: number) => void @@ -66,7 +66,8 @@ const Parameters: React.FC = ({ parameter={parameter} setParamName={name => setParamName(idx, name)} setParamValue={value => setParamValue(idx, value)} - setParamTimestamp={timestamp => setParamTimestamp(idx, timestamp)} + setParamTimestamp={setParamTimestamp} + idx={idx} removeParam={() => removeParam(idx)} isUserProperty={isUserProperty} /> diff --git a/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx b/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx new file mode 100644 index 000000000..109ca953a --- /dev/null +++ b/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx @@ -0,0 +1,201 @@ +import * as React from "react" +import { render, fireEvent, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import "@testing-library/jest-dom" +import TimestampPicker from "./TimestampPicker" +import { UseFirebaseCtx, Label } from "." + +describe("TimestampPicker", () => { + const setTimestamp = jest.fn() + + beforeEach(() => { + setTimestamp.mockClear() + }) + + it("renders with initial values", () => { + render( + + + + ) + + expect(screen.getByLabelText(Label.TimestampMicros)).toHaveValue( + "1678886400000000" + ) + }) + + it("calls setTimestamp when text input changes", () => { + render( + + + + ) + + const input = screen.getByLabelText(Label.TimestampMicros) + fireEvent.change(input, { target: { value: "1678886400000001" } }) + + expect(setTimestamp).toHaveBeenCalledWith("1678886400000001") + }) + + it("shows validation error for invalid timestamp", () => { + render( + + + + ) + + const input = screen.getByLabelText(Label.TimestampMicros) + fireEvent.change(input, { target: { value: "invalid" } }) + + expect(screen.getByText("Timestamp must be a number.")).toBeInTheDocument() + }) + + it("shows validation error for negative timestamp", () => { + render( + + + + ) + + const input = screen.getByLabelText(Label.TimestampMicros) + fireEvent.change(input, { target: { value: "-100" } }) + + expect( + screen.getByText("Timestamp must be a positive number.") + ).toBeInTheDocument() + }) + + it("clears validation error for empty timestamp", () => { + render( + + + + ) + + const input = screen.getByLabelText(Label.TimestampMicros) + fireEvent.change(input, { target: { value: "invalid" } }) + fireEvent.change(input, { target: { value: "" } }) + + expect( + screen.getByText("The timestamp of the event. Optional.") + ).toBeInTheDocument() + }) + + it("sets timestamp to current time", () => { + jest.useFakeTimers("modern") + jest.setSystemTime(new Date("2023-03-15T12:00:00.000Z")) + + const expectedTimestamp = "1678881600000000" + + render( + + + + ) + + const button = screen.getByRole("button", { name: "Set to current time" }) + fireEvent.click(button) + + expect(setTimestamp).toHaveBeenCalledWith(expectedTimestamp) + + jest.useRealTimers() + }) + + it("initializes timezone field to user's timezone", async () => { + // Mock browser's timezone as America/New_York + jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + timeZone: "America/New_York", + locale: "en-US", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + calendar: "gregory", + numberingSystem: "latn", + }) + + render( + + + + ) + + const timezoneButton = screen.getByRole("button", { name: "Select timezone" }) + userEvent.click(timezoneButton) + + expect(screen.getByLabelText(Label.TimezoneSelect)).toHaveValue("America/New_York") + jest.restoreAllMocks() + }) + + it("updates timestamp when timezone changes", async () => { + const initialTimestamp = "1678881600000000" + const expectedTimestamp = "1678892400000000" + + jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + timeZone: "America/New_York", + locale: "en-US", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + calendar: "gregory", + numberingSystem: "latn", + }) + + render( + + + + ) + + const timezoneButton = screen.getByRole("button", { name: "Select timezone" }) + userEvent.click(timezoneButton) + + const timezoneInput = screen.getByLabelText(Label.TimezoneSelect) + await userEvent.type(timezoneInput, "America/Los_Angeles", { delay: 1 }) + + const laOption = await screen.findByText("America/Los_Angeles") + userEvent.click(laOption) + + expect(setTimestamp).toHaveBeenCalledWith(expectedTimestamp) + + jest.restoreAllMocks() + }) +}) + diff --git a/src/components/ga4/EventBuilder/TimestampPicker.tsx b/src/components/ga4/EventBuilder/TimestampPicker.tsx new file mode 100644 index 000000000..45f4a6146 --- /dev/null +++ b/src/components/ga4/EventBuilder/TimestampPicker.tsx @@ -0,0 +1,187 @@ +import * as React from "react" +import { + Grid, + Tooltip, + IconButton, + Popover, + Box, +} from "@mui/material" +import { Refresh, Public } from "@mui/icons-material" +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker" +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +import timezone from "dayjs/plugin/timezone" +import { Label, UseFirebaseCtx } from "." +import LinkedTextField from "@/components/LinkedTextField" +import TimezoneSelect from "./TimezoneSelect" +import { TimestampScope } from "@/constants" +dayjs.extend(utc) +dayjs.extend(timezone) + +interface TimestampPickerProps { + timestamp: string + scope: string + setTimestamp: (value: string) => void +} + +const TimestampPicker: React.FC = ({ + timestamp, + scope, + setTimestamp, +}) => { + + const useFirebase = React.useContext(UseFirebaseCtx) + const [selectedTimezone, setSelectedTimezone] = React.useState( + Intl.DateTimeFormat().resolvedOptions().timeZone + ) + const [error, setError] = React.useState("") + const [timezoneAnchorEl, setTimezoneAnchorEl] = + React.useState(null) + + const handleTimezoneOpen = (event: React.MouseEvent) => { + setTimezoneAnchorEl(event.currentTarget) + } + + const handleTimezoneClose = () => { + setTimezoneAnchorEl(null) + } + + const timezonePopoverOpen = Boolean(timezoneAnchorEl) + const timezonePopoverId = timezonePopoverOpen ? "timezone-popover" : undefined + + const handleTimezoneChange = (newTimezone: string) => { + if (timestamp && !isNaN(parseInt(timestamp, 10))) { + const currentTime = dayjs + .utc(parseInt(timestamp, 10) / 1000) + .tz(selectedTimezone) + + const newTime = currentTime.tz(newTimezone, true) + const newTimestamp = newTime.valueOf() * 1000 + setTimestamp(newTimestamp.toString()) + } + setSelectedTimezone(newTimezone) + } + + const validate = (value: string) => { + if (value === "") { + setError("") + return true + } + const num = parseInt(value, 10) + if (isNaN(num)) { + setError("Timestamp must be a number.") + return false + } + if (num < 0) { + setError("Timestamp must be a positive number.") + return false + } + setError("") + return true + } + + const docsBaseUrl = "https://developers.google.com/analytics/devguides/collection/protocol/ga4" + const clientType = useFirebase ? "firebase" : "gtag" + const href = + scope === TimestampScope.USER_PROPERTY + ? `${docsBaseUrl}/user-properties?client_type=${clientType}#override_timestamp` + : `${docsBaseUrl}/sending-events?client_type=${clientType}#override_timestamp` + + return ( + + + + + { + setTimestamp(value) + validate(value) + }} + label={Label.TimestampMicros} + linkTitle="Go to documentation" + href={href} + extraAction={ + + { + const newTimestamp = new Date().getTime() * 1000 + setTimestamp(newTimestamp.toString()) + setError("") + }} + > + + + + } + /> + + + { + if (newValue) { + const newTimestamp = newValue.valueOf() * 1000 + setTimestamp(newTimestamp.toString()) + setError("") + } + }} + slotProps={{ textField: { helperText: " " } }} + /> + + + + + + + + + + + + + + + + + ) +} + +export default TimestampPicker \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/TimezoneSelect.tsx b/src/components/ga4/EventBuilder/TimezoneSelect.tsx new file mode 100644 index 000000000..7676df8fb --- /dev/null +++ b/src/components/ga4/EventBuilder/TimezoneSelect.tsx @@ -0,0 +1,37 @@ +import * as React from "react" +import { Autocomplete, TextField } from "@mui/material" +import { Label } from "." + +const timezones = Intl.supportedValuesOf("timeZone") + +interface TimezoneSelectProps { + selectedTimezone: string + setSelectedTimezone: (timezone: string) => void +} + +const TimezoneSelect: React.FC = ({ + selectedTimezone, + setSelectedTimezone, +}) => { + return ( + { + if (newValue) { + setSelectedTimezone(newValue) + } + }} + renderInput={params => ( + + )} + /> + ) +} + +export default TimezoneSelect diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index f3ffb79ef..2a0e29d6d 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -18,10 +18,7 @@ import { styled } from '@mui/material/styles'; import Typography from "@mui/material/Typography" import TextField from "@mui/material/TextField" -import IconButton from "@mui/material/IconButton" -import Tooltip from "@mui/material/Tooltip" import Autocomplete from "@mui/material/Autocomplete" -import Refresh from "@mui/icons-material/Refresh" import { Error as ErrorIcon } from "@mui/icons-material" import LinkedTextField from "@/components/LinkedTextField" @@ -30,7 +27,7 @@ import LabeledCheckbox from "@/components/LabeledCheckbox" import Grid from "@mui/material/Grid" import Switch from "@mui/material/Switch" import ExternalLink from "@/components/ExternalLink" -import { Url } from "@/constants" +import { TimestampScope, Url } from "@/constants" import WithHelpText from "@/components/WithHelpText" import { TooltipIconButton } from "@/components/Buttons" import useEvent from "./useEvent" @@ -43,6 +40,7 @@ import Items from "./Items" import ValidateEvent from "./ValidateEvent" import { PlainButton } from "@/components/Buttons" import { useEffect } from "react" +import TimestampPicker from "./TimestampPicker" const PREFIX = 'EventBuilder'; @@ -119,7 +117,8 @@ export enum Label { EventCategory = "event category", EventName = "event name", - TimestampMicros = "timestamp micros", + TimestampMicros = "UNIX timestamp in microseconds", + TimezoneSelect = "Timezone", NonPersonalizedAds = "non personalized ads", Payload = "payload", @@ -155,7 +154,7 @@ export type EventPayload = { useTextBox: boolean payloadObj: any } -export const EventCtx = React.createContext< +export const EventCtx = React.createContext< | EventPayload | undefined >(undefined) @@ -370,7 +369,7 @@ const EventBuilder: React.FC = () => { )} { { !useTextBox &&
-
+
@@ -531,28 +530,10 @@ const EventBuilder: React.FC = () => { )} /> )} - - { - setTimestampMicros((new Date().getTime() * 1000).toString()) - }} - > - - - - } + { parameters, eventName, userProperties, - timestamp_micros: (num => isNaN(num) ? undefined : num)(parseFloat(timestamp_micros || '')), + timestamp_micros: ((num) => (isNaN(num) ? undefined : num))( + parseInt(timestamp_micros || "", 10) + ), non_personalized_ads, useTextBox, payloadObj, diff --git a/src/components/ga4/EventBuilder/useUserProperties.ts b/src/components/ga4/EventBuilder/useUserProperties.ts index 072b5bc9c..7a9f91bd8 100644 --- a/src/components/ga4/EventBuilder/useUserProperties.ts +++ b/src/components/ga4/EventBuilder/useUserProperties.ts @@ -44,7 +44,7 @@ const useUserProperties = () => { ) const setUserPropertyTimestamp = useCallback( - (idx: number, timestamp_micros: number) => { + (idx: number, timestamp_micros: number | undefined) => { updateUserProperty(idx, old => ({ ...old, timestamp_micros })) }, [updateUserProperty] diff --git a/src/constants.ts b/src/constants.ts index 48d869f6d..0398c5695 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -263,3 +263,8 @@ export const EventAction = { export const EventCategory = { campaignUrlBuilder: "Campaign URL Builder", } + +export const TimestampScope = { + USER_PROPERTY: "user property", + REQUEST: "event", +} From bcdbaf665a8f9177def4c142bd370357dd487b6b Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Mon, 15 Sep 2025 20:11:07 +0000 Subject: [PATCH 06/12] add package.json changes --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f7dd45505..05e16d3f9 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "@mui/material": "^5.13.0", "@mui/styles": "^5.14.15", "@mui/x-data-grid": "^6.4.0", + "@mui/x-date-pickers": "^8.11.2", "@typescript-eslint/eslint-plugin": "^4.0.0", "@typescript-eslint/parser": "^4.0.0", "babel-eslint": "^10.0.0", "classnames": "^2.3.2", "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.18", "eslint": "^7.0.0", "gatsby": "^5.9.1", "gatsby-plugin-emotion": "^8.9.0", @@ -44,8 +46,8 @@ "react-helmet": "^6.1.0", "react-icons": "^4.8.0", "react-json-view": "^1.21.3", - "react-markdown": "^9.0.1", "react-loader-spinner": "^6.1.6", + "react-markdown": "^9.0.1", "react-redux": "^8.0.5", "react-syntax-highlighter": "^15.5.0", "redux": "^4.2.1", @@ -106,7 +108,7 @@ "react": "^18.2.0" } }, - "resolutions": { + "resolutions": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0" }, From 18929abd7285b82429a5c0a4f0c637213e797e1e Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Mon, 15 Sep 2025 20:27:15 +0000 Subject: [PATCH 07/12] rename isUserProperty prop to be more clear --- src/components/ga4/EventBuilder/Items.tsx | 2 +- src/components/ga4/EventBuilder/Parameter.tsx | 6 +++--- src/components/ga4/EventBuilder/Parameters.tsx | 6 +++--- src/components/ga4/EventBuilder/index.tsx | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/ga4/EventBuilder/Items.tsx b/src/components/ga4/EventBuilder/Items.tsx index 1d6e0606f..f05d13ee9 100644 --- a/src/components/ga4/EventBuilder/Items.tsx +++ b/src/components/ga4/EventBuilder/Items.tsx @@ -83,7 +83,7 @@ const Items: React.FC = ({ removeParam={(itemIdx: number) => removeItemParam(idx, itemIdx)} removeItem={() => removeItem(idx)} setParamTimestamp={() => {}} - isUserProperty={false} + allowTimestampOverride={false} /> ))} diff --git a/src/components/ga4/EventBuilder/Parameter.tsx b/src/components/ga4/EventBuilder/Parameter.tsx index 9f18958b4..2312ca194 100644 --- a/src/components/ga4/EventBuilder/Parameter.tsx +++ b/src/components/ga4/EventBuilder/Parameter.tsx @@ -19,7 +19,7 @@ interface Props { setParamValue: (value: string) => void setParamTimestamp: (idx: number, value: number | undefined) => void removeParam: () => void - isUserProperty: boolean + allowTimestampOverride: boolean } const Parameter: React.FC = ({ @@ -29,7 +29,7 @@ const Parameter: React.FC = ({ setParamValue, setParamTimestamp, removeParam, - isUserProperty, + allowTimestampOverride, }) => { const showAdvanced = React.useContext(ShowAdvancedCtx) @@ -73,7 +73,7 @@ const Parameter: React.FC = ({ fullWidth /> - {isUserProperty && ( + {allowTimestampOverride && ( void removeItem?: () => void addItemsParam?: () => void - isUserProperty: boolean + allowTimestampOverride: boolean } const Parameters: React.FC = ({ @@ -54,7 +54,7 @@ const Parameters: React.FC = ({ removeParam, addItemsParam, removeItem, - isUserProperty, + allowTimestampOverride: allowTimestampOverride, }) => { const showAdvanced = React.useContext(ShowAdvancedCtx) @@ -69,7 +69,7 @@ const Parameters: React.FC = ({ setParamTimestamp={setParamTimestamp} idx={idx} removeParam={() => removeParam(idx)} - isUserProperty={isUserProperty} + allowTimestampOverride={allowTimestampOverride} /> ))}
diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 2a0e29d6d..f850957f0 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -577,7 +577,7 @@ const EventBuilder: React.FC = () => { setParamValue={setParamValue} addItemsParam={items === undefined ? addItemsParam : undefined} setParamTimestamp={() => {}} - isUserProperty={false} + allowTimestampOverride={false} /> {items !== undefined && ( <> @@ -607,7 +607,7 @@ const EventBuilder: React.FC = () => { setParamName={setUserPropertyName} setParamValue={setUserPropertyValue} setParamTimestamp={setUserPropertyTimestamp} - isUserProperty={true} + allowTimestampOverride={true} /> )} From 2981d578f9c642619300508b80cb76521942d9be Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Mon, 15 Sep 2025 21:28:59 +0000 Subject: [PATCH 08/12] fix type in ValidateEvent/index.spec.tsx --- src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx index 0ed90e1a2..db19141ba 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx +++ b/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx @@ -47,7 +47,7 @@ const renderComponent = (props: Partial = {}) => { parameters: [], items: [], userProperties: [], - timestamp_micros: "", + timestamp_micros: undefined, non_personalized_ads: false, useTextBox: false, payloadObj: [], From 911ff67a0bfafc155609f47089600badfd2d8303 Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Thu, 18 Sep 2025 15:44:06 +0000 Subject: [PATCH 09/12] Merge remote-tracking branch 'origin/main' into overwrite-timestamp --- .../EventBuilder/GeographicInformation.tsx | 138 ++++++++++++++++++ .../ga4/EventBuilder/TimestampPicker.spec.tsx | 6 +- .../ga4/EventBuilder/TimestampPicker.tsx | 3 +- .../ga4/EventBuilder/TimezoneSelect.tsx | 2 +- .../EventBuilder/ValidateEvent/index.spec.tsx | 8 + .../ga4/EventBuilder/ValidateEvent/index.tsx | 3 +- .../ValidateEvent/schemas/baseContent.spec.ts | 12 ++ .../ValidateEvent/schemas/baseContent.ts | 51 ++++--- .../ValidateEvent/schemas/userLocation.ts | 23 +++ .../EventBuilder/ValidateEvent/usePayload.ts | 26 +++- .../ga4/EventBuilder/index.spec.tsx | 3 +- src/components/ga4/EventBuilder/index.tsx | 132 +++++++++-------- src/components/ga4/EventBuilder/types.ts | 38 +++++ 13 files changed, 352 insertions(+), 93 deletions(-) create mode 100644 src/components/ga4/EventBuilder/GeographicInformation.tsx create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/userLocation.ts diff --git a/src/components/ga4/EventBuilder/GeographicInformation.tsx b/src/components/ga4/EventBuilder/GeographicInformation.tsx new file mode 100644 index 000000000..4cb03455b --- /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/TimestampPicker.spec.tsx b/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx index 109ca953a..9334c1bca 100644 --- a/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx +++ b/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx @@ -2,8 +2,9 @@ import * as React from "react" import { render, fireEvent, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import "@testing-library/jest-dom" -import TimestampPicker from "./TimestampPicker" -import { UseFirebaseCtx, Label } from "." +import TimestampPicker from './TimestampPicker'; +import { UseFirebaseCtx } from '.'; +import { Label } from './types'; describe("TimestampPicker", () => { const setTimestamp = jest.fn() @@ -198,4 +199,3 @@ describe("TimestampPicker", () => { jest.restoreAllMocks() }) }) - diff --git a/src/components/ga4/EventBuilder/TimestampPicker.tsx b/src/components/ga4/EventBuilder/TimestampPicker.tsx index 45f4a6146..1b4f959c6 100644 --- a/src/components/ga4/EventBuilder/TimestampPicker.tsx +++ b/src/components/ga4/EventBuilder/TimestampPicker.tsx @@ -13,7 +13,8 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider" import dayjs from "dayjs" import utc from "dayjs/plugin/utc" import timezone from "dayjs/plugin/timezone" -import { Label, UseFirebaseCtx } from "." +import { UseFirebaseCtx } from "." +import { Label } from "./types" import LinkedTextField from "@/components/LinkedTextField" import TimezoneSelect from "./TimezoneSelect" import { TimestampScope } from "@/constants" diff --git a/src/components/ga4/EventBuilder/TimezoneSelect.tsx b/src/components/ga4/EventBuilder/TimezoneSelect.tsx index 7676df8fb..f1baa15ee 100644 --- a/src/components/ga4/EventBuilder/TimezoneSelect.tsx +++ b/src/components/ga4/EventBuilder/TimezoneSelect.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Autocomplete, TextField } from "@mui/material" -import { Label } from "." +import { Label } from "./types" const timezones = Intl.supportedValuesOf("timeZone") diff --git a/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx index db19141ba..ba25f27cb 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 4bff53753..0154dc47d 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 d34705f83..68e5b7e25 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 303834fb6..0245e26be 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 000000000..799db1374 --- /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 4880e8e67..0b5701ee3 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts @@ -75,7 +75,9 @@ const usePayload = (): {} => { clientIds, type, useTextBox, - payloadObj + payloadObj, + ip_override, + user_location, } = useContext(EventCtx)! const eventName = useMemo(() => { @@ -97,22 +99,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 } : {}) }, ], @@ -125,6 +139,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 3976167b7..150e16795 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 622e80c16..81771e190 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -33,7 +33,7 @@ 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" @@ -41,6 +41,7 @@ import ValidateEvent from "./ValidateEvent" import { PlainButton } from "@/components/Buttons" import { useEffect } from "react" import TimestampPicker from "./TimestampPicker" +import GeographicInformation from "./GeographicInformation"; const PREFIX = 'EventBuilder'; @@ -104,36 +105,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 = "UNIX timestamp in microseconds", - TimezoneSelect = "Timezone", - 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 @@ -153,6 +124,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 @@ -233,18 +212,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({}) @@ -556,7 +540,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 @@ -594,26 +579,49 @@ const EventBuilder: React.FC = () => { /> )} - {(showAdvanced || - (userProperties !== undefined && userProperties.length !== 0)) && ( - <> - User properties - + User properties + - - )} - -
-
- } + /> + + )} + {showAdvanced && ( + <> + + + )} + +
+ + } Validate & Send event @@ -636,6 +644,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 c2c04f63c..8b520727e 100644 --- a/src/components/ga4/EventBuilder/types.ts +++ b/src/components/ga4/EventBuilder/types.ts @@ -86,6 +86,44 @@ 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 = "UNIX timestamp in microseconds", + TimezoneSelect = "Timezone", + 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", From 7a32a56eacf5bc179d70611e1b9c82aab22b808b Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Thu, 18 Sep 2025 15:48:28 +0000 Subject: [PATCH 10/12] resolve conflicts --- src/components/ga4/EventBuilder/index.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 87ba3a237..0f6852b94 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -651,15 +651,7 @@ const EventBuilder: React.FC = () => { country_id: user_location_country_id, subcontinent_id: user_location_subcontinent_id, continent_id: user_location_continent_id, - }, - 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, - }, + } }} > Date: Tue, 23 Sep 2025 20:32:01 +0000 Subject: [PATCH 11/12] use iso-8601 format, add helper text, simplify value updates --- .../ga4/EventBuilder/TimestampPicker.tsx | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/src/components/ga4/EventBuilder/TimestampPicker.tsx b/src/components/ga4/EventBuilder/TimestampPicker.tsx index 1b4f959c6..9047a7444 100644 --- a/src/components/ga4/EventBuilder/TimestampPicker.tsx +++ b/src/components/ga4/EventBuilder/TimestampPicker.tsx @@ -41,6 +41,12 @@ const TimestampPicker: React.FC = ({ const [timezoneAnchorEl, setTimezoneAnchorEl] = React.useState(null) + const datetimeValue = React.useMemo(() => { + return timestamp && !isNaN(parseInt(timestamp, 10)) + ? dayjs.utc(parseInt(timestamp, 10) / 1000).tz(selectedTimezone) + : null + }, [timestamp, selectedTimezone]) + const handleTimezoneOpen = (event: React.MouseEvent) => { setTimezoneAnchorEl(event.currentTarget) } @@ -52,18 +58,17 @@ const TimestampPicker: React.FC = ({ const timezonePopoverOpen = Boolean(timezoneAnchorEl) const timezonePopoverId = timezonePopoverOpen ? "timezone-popover" : undefined - const handleTimezoneChange = (newTimezone: string) => { - if (timestamp && !isNaN(parseInt(timestamp, 10))) { - const currentTime = dayjs - .utc(parseInt(timestamp, 10) / 1000) - .tz(selectedTimezone) - - const newTime = currentTime.tz(newTimezone, true) - const newTimestamp = newTime.valueOf() * 1000 - setTimestamp(newTimestamp.toString()) - } - setSelectedTimezone(newTimezone) - } + const handleTimezoneChange = React.useCallback( + (newTimezone: string) => { + if (datetimeValue) { + const newTime = datetimeValue.tz(newTimezone, true) + const newTimestamp = newTime.valueOf() * 1000 + setTimestamp(newTimestamp.toString()) + } + setSelectedTimezone(newTimezone) + }, + [datetimeValue, setTimestamp, setSelectedTimezone] + ) const validate = (value: string) => { if (value === "") { @@ -91,10 +96,7 @@ const TimestampPicker: React.FC = ({ : `${docsBaseUrl}/sending-events?client_type=${clientType}#override_timestamp` return ( - + @@ -132,15 +134,10 @@ const TimestampPicker: React.FC = ({ { if (newValue) { const newTimestamp = newValue.valueOf() * 1000 @@ -148,7 +145,7 @@ const TimestampPicker: React.FC = ({ setError("") } }} - slotProps={{ textField: { helperText: " " } }} + slotProps={{ textField: { helperText: `In ${selectedTimezone}` } }} /> From 8861633d83a4198e64ff8f7560f698deafaa150f Mon Sep 17 00:00:00 2001 From: Lindsey Volta Date: Tue, 23 Sep 2025 20:45:03 +0000 Subject: [PATCH 12/12] remove unused import --- src/components/ga4/EventBuilder/Parameter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ga4/EventBuilder/Parameter.tsx b/src/components/ga4/EventBuilder/Parameter.tsx index 2312ca194..716da8848 100644 --- a/src/components/ga4/EventBuilder/Parameter.tsx +++ b/src/components/ga4/EventBuilder/Parameter.tsx @@ -8,7 +8,7 @@ import { Tooltip, Grid } from "@mui/material" -import { Delete, ExpandMore } from "@mui/icons-material" +import { Delete } from "@mui/icons-material" import TimestampPicker from "./TimestampPicker" import { TimestampScope } from "@/constants"