diff --git a/package.json b/package.json index bbc6cc79..2c548af3 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", @@ -104,7 +106,7 @@ "react": "^18.2.0" } }, - "resolutions": { + "resolutions": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0" }, diff --git a/src/components/ExternalLink.tsx b/src/components/ExternalLink.tsx index ffca8f80..db391b55 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 1dde9230..45d3ccde 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/Items.tsx b/src/components/ga4/EventBuilder/Items.tsx index 7b525187..f05d13ee 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={() => {}} + allowTimestampOverride={false} /> ))} diff --git a/src/components/ga4/EventBuilder/Parameter.tsx b/src/components/ga4/EventBuilder/Parameter.tsx index 5e150978..716da884 100644 --- a/src/components/ga4/EventBuilder/Parameter.tsx +++ b/src/components/ga4/EventBuilder/Parameter.tsx @@ -1,91 +1,106 @@ 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), - }, - } -})); +import TimestampPicker from "./TimestampPicker" +import { TimestampScope } from "@/constants" interface Props { parameter: ParameterT + idx: number setParamName: (name: string) => void setParamValue: (value: string) => void + setParamTimestamp: (idx: number, value: number | undefined) => void removeParam: () => void + allowTimestampOverride: boolean } const Parameter: React.FC = ({ parameter, + idx, setParamName, setParamValue, + setParamTimestamp, removeParam, + allowTimestampOverride, }) => { - 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() || "" + ) + + React.useEffect(() => { + const num = parseInt(timestamp, 10) + setParamTimestamp(idx, isNaN(num) ? undefined : num) + }, [timestamp, setParamTimestamp, idx]) 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 + /> + + {allowTimestampOverride && ( + + + + )} + ) if (showAdvanced) { return ( -
- - - - - - {inputs} -
+ + + + + + + + + + {inputs} + + ) } 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 26274b61..39d931ae 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 | undefined) => void addStringParam: () => void addNumberParam: () => void removeParam: (idx: number) => void removeItem?: () => void addItemsParam?: () => void + allowTimestampOverride: boolean } const Parameters: React.FC = ({ parameters, setParamName, setParamValue, + setParamTimestamp, addStringParam, addNumberParam, removeParam, addItemsParam, removeItem, + allowTimestampOverride: allowTimestampOverride, }) => { const showAdvanced = React.useContext(ShowAdvancedCtx) @@ -62,7 +66,10 @@ const Parameters: React.FC = ({ parameter={parameter} setParamName={name => setParamName(idx, name)} setParamValue={value => setParamValue(idx, value)} + setParamTimestamp={setParamTimestamp} + idx={idx} removeParam={() => removeParam(idx)} + allowTimestampOverride={allowTimestampOverride} /> ))}
diff --git a/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx b/src/components/ga4/EventBuilder/TimestampPicker.spec.tsx new file mode 100644 index 00000000..9334c1bc --- /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 } from '.'; +import { Label } from './types'; + +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 00000000..9047a744 --- /dev/null +++ b/src/components/ga4/EventBuilder/TimestampPicker.tsx @@ -0,0 +1,185 @@ +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 { UseFirebaseCtx } from "." +import { Label } from "./types" +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 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) + } + + const handleTimezoneClose = () => { + setTimezoneAnchorEl(null) + } + + const timezonePopoverOpen = Boolean(timezoneAnchorEl) + const timezonePopoverId = timezonePopoverOpen ? "timezone-popover" : undefined + + 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 === "") { + 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: `In ${selectedTimezone}` } }} + /> + + + + + + + + + + + + + + + + + ) +} + +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 00000000..f1baa15e --- /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 "./types" + +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/ValidateEvent/index.spec.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.spec.tsx index 1b0e3207..e958ce94 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: [], diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts index db7876ac..39a47753 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/ValidateEvent/schemas/userProperties.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts index 7101a527..7ddb0be5 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 236653f5..c90d99d6 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.spec.tsx b/src/components/ga4/EventBuilder/index.spec.tsx index 34362a0a..150e1679 100644 --- a/src/components/ga4/EventBuilder/index.spec.tsx +++ b/src/components/ga4/EventBuilder/index.spec.tsx @@ -131,7 +131,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"/) }) @@ -207,7 +207,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"/) }) diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 530ac169..d2c20e16 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" import GeographicInformation from "./GeographicInformation"; import DeviceInformation from "./DeviceInformation"; @@ -93,6 +91,9 @@ const Root = styled('div')(( [`& .${classes.form}`]: { maxWidth: "80ch", + "& h5:not(:first-of-type)": { + marginTop: theme.spacing(4), + }, }, [`& .${classes.items}`]: { @@ -117,7 +118,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 @@ -145,7 +146,7 @@ export type EventPayload = { browser_version: string | undefined } } -export const EventCtx = React.createContext< +export const EventCtx = React.createContext< | EventPayload | undefined >(undefined) @@ -161,6 +162,7 @@ const EventBuilder: React.FC = () => { removeUserProperty, setUserPropertyName, setUserPropertyValue, + setUserPropertyTimestamp, } = useUserProperties() const { @@ -375,7 +377,7 @@ const EventBuilder: React.FC = () => { )} { { !useTextBox &&
-
+
@@ -536,28 +538,10 @@ const EventBuilder: React.FC = () => { )} /> )} - - { - setTimestampMicros((new Date().getTime() * 1000).toString()) - }} - > - - - - } + { setParamName={setParamName} setParamValue={setParamValue} addItemsParam={items === undefined ? addItemsParam : undefined} + setParamTimestamp={() => {}} + allowTimestampOverride={false} /> {items !== undefined && ( <> @@ -630,6 +616,8 @@ const EventBuilder: React.FC = () => { addNumberParam={addNumberUserProperty} setParamName={setUserPropertyName} setParamValue={setUserPropertyValue} + setParamTimestamp={setUserPropertyTimestamp} + allowTimestampOverride={true} /> )} @@ -699,7 +687,9 @@ const EventBuilder: React.FC = () => { parameters, eventName, userProperties, - 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/types.ts b/src/components/ga4/EventBuilder/types.ts index baa3d36d..107c2d34 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 @@ -97,7 +99,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", diff --git a/src/components/ga4/EventBuilder/useUserProperties.ts b/src/components/ga4/EventBuilder/useUserProperties.ts index ce44f934..7a9f91bd 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 | undefined) => { + updateUserProperty(idx, old => ({ ...old, timestamp_micros })) + }, + [updateUserProperty] + ) + return { userProperties: userProperties || [], addStringUserProperty, @@ -50,6 +57,7 @@ const useUserProperties = () => { removeUserProperty, setUserPropertyValue, setUserPropertyName, + setUserPropertyTimestamp, } } diff --git a/src/constants.ts b/src/constants.ts index 48d869f6..0398c569 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", +}