From 914309b7817aca8f656f3e79f2b748c7f54e3724 Mon Sep 17 00:00:00 2001 From: Sina Javaheri Date: Tue, 12 Dec 2023 10:42:08 +0100 Subject: [PATCH] feat(google-analatics): add google analytics --- .env.development | 4 +- .env.example | 4 +- .env.production | 4 +- src/core/events/WebSocketEvents.tsx | 96 +++++++++--- src/pages/panel/inventory/InventoryForm.tsx | 12 +- .../InventoryFormFilterRowProperty.tsx | 45 +++++- .../InventoryFormFilterRowStringValue.tsx | 20 ++- src/pages/panel/inventory/InventoryTable.tsx | 13 +- .../inventory/InventoryTagAutoComplete.tsx | 17 +- src/pages/panel/inventory/ResourceDetail.tsx | 11 +- .../inventory/utils/useInventorySendToGTM.ts | 64 ++++++++ .../AbsoluteNavigateProvider.tsx | 30 +++- src/shared/constants/env.ts | 5 +- .../ErrorBoundaryFallback.tsx | 42 ++++- .../google-tag-manager/GoogleTagManager.tsx | 61 ++++++++ .../GoogleTagManagerTypes.ts | 147 ++++++++++++++++++ src/shared/google-tag-manager/index.ts | 3 + src/shared/google-tag-manager/initGTM.ts | 54 +++++++ src/shared/google-tag-manager/sendToGTM.ts | 16 ++ .../google-tag-manager/useGTMDispatch.ts | 11 ++ src/shared/google-tag-manager/utils.ts | 63 ++++++++ src/shared/types/global/gtag.d.ts | 7 + src/shared/types/global/vite-client.d.ts | 2 + src/shared/utils/Providers.tsx | 54 ++++--- src/shared/utils/getEnviroment.ts | 22 +++ src/shared/utils/jsonToStr.ts | 1 + 26 files changed, 750 insertions(+), 58 deletions(-) create mode 100644 src/pages/panel/inventory/utils/useInventorySendToGTM.ts create mode 100644 src/shared/google-tag-manager/GoogleTagManager.tsx create mode 100644 src/shared/google-tag-manager/GoogleTagManagerTypes.ts create mode 100644 src/shared/google-tag-manager/index.ts create mode 100644 src/shared/google-tag-manager/initGTM.ts create mode 100644 src/shared/google-tag-manager/sendToGTM.ts create mode 100644 src/shared/google-tag-manager/useGTMDispatch.ts create mode 100644 src/shared/google-tag-manager/utils.ts create mode 100644 src/shared/types/global/gtag.d.ts create mode 100644 src/shared/utils/getEnviroment.ts create mode 100644 src/shared/utils/jsonToStr.ts diff --git a/.env.development b/.env.development index 8e5d9b07..58e67339 100644 --- a/.env.development +++ b/.env.development @@ -6,4 +6,6 @@ VITE_WEBSOCKET_RETRY_TIMEOUT=5000 HOST=127.0.0.1 PORT=8081 VITE_USE_MOCK=false -VITE_DISCORD_URL=https://discord.gg/someengineering \ No newline at end of file +VITE_DISCORD_URL=https://discord.gg/someengineering +VITE_GTM_DEV_ID=G-BEN98CFE8C +VITE_GTM_PROD_ID= \ No newline at end of file diff --git a/.env.example b/.env.example index 8e5d9b07..b046e1ae 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,6 @@ VITE_WEBSOCKET_RETRY_TIMEOUT=5000 HOST=127.0.0.1 PORT=8081 VITE_USE_MOCK=false -VITE_DISCORD_URL=https://discord.gg/someengineering \ No newline at end of file +VITE_DISCORD_URL=https://discord.gg/someengineering +VITE_GTM_DEV_ID=G-BEN98CFE8C +VITE_GTM_PROD_ID=G-WBQZ5WW9X1 \ No newline at end of file diff --git a/.env.production b/.env.production index 4f6ae83b..cbaf32e6 100644 --- a/.env.production +++ b/.env.production @@ -2,4 +2,6 @@ VITE_USE_PROXY=false VITE_NETWORK_RETRY_COUNT=5 VITE_WEBSOCKET_RETRY_TIMEOUT=5000 VITE_USE_MOCK=false -VITE_DISCORD_URL=https://discord.gg/someengineering \ No newline at end of file +VITE_DISCORD_URL=https://discord.gg/someengineering +VITE_GTM_DEV_ID=G-BEN98CFE8C +VITE_GTM_PROD_ID=G-WBQZ5WW9X1 \ No newline at end of file diff --git a/src/core/events/WebSocketEvents.tsx b/src/core/events/WebSocketEvents.tsx index 70559177..70663e04 100644 --- a/src/core/events/WebSocketEvents.tsx +++ b/src/core/events/WebSocketEvents.tsx @@ -1,6 +1,7 @@ import { PropsWithChildren, useCallback, useEffect, useRef } from 'react' import { useUserProfile } from 'src/core/auth' import { endPoints, env } from 'src/shared/constants' +import { useGTMDispatch } from 'src/shared/google-tag-manager' import { WebSocketEvent } from 'src/shared/types/server' import { WebSocketEventsContext } from './WebSocketEventsContext' @@ -8,6 +9,7 @@ const WS_CLOSE_CODE_NO_RETRY = 4001 export const WebSocketEvents = ({ children }: PropsWithChildren) => { const { selectedWorkspace, isAuthenticated, logout } = useUserProfile() + const sendToGTM = useGTMDispatch() const noRetry = useRef(false) const listeners = useRef void>>({}) const messagesToSend = useRef<{ message: string; resolve: (value: string) => void; reject: (err: unknown) => void }[]>([]) @@ -32,8 +34,19 @@ export const WebSocketEvents = ({ children }: PropsWithChildren) => { } else { onMessage(message as WebSocketEvent) } - } catch { - /* empty */ + } catch (err) { + const { message, name, stack = 'unknown' } = err as Error + sendToGTM({ + event: 'socket-error', + api: `${env.wsUrl}/${endPoints.workspaces.workspace(selectedWorkspace?.id ?? 'unknown').events}`, + authorized: isAuthenticated ?? false, + message: message, + name, + stack, + state: 'on-message', + params: ev.data, + workspaceId: selectedWorkspace?.id ?? 'unknown', + }) } } if (websocket.current) { @@ -41,34 +54,63 @@ export const WebSocketEvents = ({ children }: PropsWithChildren) => { } return () => handleRemoveListener(randomId) }, - [handleRemoveListener, logout], + [handleRemoveListener, isAuthenticated, logout, selectedWorkspace?.id, sendToGTM], ) - const handleSendData = useCallback((data: WebSocketEvent) => { - const message = JSON.stringify(data) - return new Promise((resolve, reject) => { - try { - if (websocket.current && websocket.current.readyState === websocket.current.OPEN) { - websocket.current.send(message) - resolve(message) - } else if (websocket.current && websocket.current.readyState === websocket.current.CONNECTING) { - websocket.current.addEventListener('open', () => { - websocket.current?.send(JSON.stringify(data)) + const handleSendData = useCallback( + (data: WebSocketEvent) => { + const message = JSON.stringify(data) + return new Promise((resolve, reject) => { + try { + if (websocket.current && websocket.current.readyState === websocket.current.OPEN) { + websocket.current.send(message) resolve(message) + } else if (websocket.current && websocket.current.readyState === websocket.current.CONNECTING) { + websocket.current.addEventListener('open', () => { + websocket.current?.send(JSON.stringify(data)) + resolve(message) + }) + } else { + messagesToSend.current.push({ message, resolve, reject }) + } + } catch (err) { + const { message, name, stack = 'unknown' } = err as Error + sendToGTM({ + event: 'socket-error', + api: `${env.wsUrl}/${endPoints.workspaces.workspace(selectedWorkspace?.id ?? 'unknown').events}`, + authorized: isAuthenticated ?? false, + message: message, + name, + stack, + state: 'send-message', + params: message, + workspaceId: selectedWorkspace?.id ?? 'unknown', }) - } else { - messagesToSend.current.push({ message, resolve, reject }) + reject(err) } - } catch (err) { - reject(err) - } - }) - }, []) + }) + }, + [isAuthenticated, selectedWorkspace?.id, sendToGTM], + ) useEffect(() => { if (isAuthenticated && selectedWorkspace?.id) { let retryTimeout = env.webSocketRetryTimeout const onClose = (ev: CloseEvent) => { + if (ev.code !== 1000) { + const { stack = 'unknown', name, message } = Error('Websocket connection closed') + sendToGTM({ + event: 'socket-error', + api: `${env.wsUrl}/${endPoints.workspaces.workspace(selectedWorkspace?.id ?? 'unknown').events}`, + authorized: isAuthenticated ?? false, + message, + name, + stack, + state: 'on-open', + params: `reason: ${ev.reason}, code: ${ev.code}, was clean: ${ev.wasClean}`, + workspaceId: selectedWorkspace?.id ?? 'unknown', + }) + } if (ev.code !== WS_CLOSE_CODE_NO_RETRY && !noRetry.current) { if (isAuthenticated && selectedWorkspace?.id) { window.setTimeout(createWebSocket, retryTimeout) @@ -82,6 +124,18 @@ export const WebSocketEvents = ({ children }: PropsWithChildren) => { websocket.current?.send(message) resolve(message) } catch (err) { + const { message, name, stack = 'unknown' } = err as Error + sendToGTM({ + event: 'socket-error', + api: `${env.wsUrl}/${endPoints.workspaces.workspace(selectedWorkspace?.id ?? 'unknown').events}`, + authorized: isAuthenticated ?? false, + message: message, + name, + stack, + state: 'on-open', + params: message, + workspaceId: selectedWorkspace?.id ?? 'unknown', + }) reject(err) } } @@ -123,7 +177,7 @@ export const WebSocketEvents = ({ children }: PropsWithChildren) => { } else { noRetry.current = false } - }, [selectedWorkspace?.id, isAuthenticated]) + }, [selectedWorkspace?.id, isAuthenticated, sendToGTM]) return ( diff --git a/src/pages/panel/inventory/InventoryForm.tsx b/src/pages/panel/inventory/InventoryForm.tsx index 990c59ea..c025fd3d 100644 --- a/src/pages/panel/inventory/InventoryForm.tsx +++ b/src/pages/panel/inventory/InventoryForm.tsx @@ -1,7 +1,8 @@ import { Trans } from '@lingui/macro' import { Autocomplete, Box, Divider, Grid, Stack, TextField } from '@mui/material' import { useQuery } from '@tanstack/react-query' -import { Dispatch, SetStateAction, useMemo } from 'react' +import { AxiosError } from 'axios' +import { Dispatch, SetStateAction, useEffect, useMemo } from 'react' import { useUserProfile } from 'src/core/auth' import { DefaultPropertiesKeys } from 'src/pages/panel/shared/constants' import { getWorkspaceInventorySearchStartQuery } from 'src/pages/panel/shared/queries' @@ -10,6 +11,7 @@ import { ErrorBoundaryFallback, NetworkErrorBoundary } from 'src/shared/error-bo import { InventoryFormFilterRow } from './InventoryFormFilterRow' import { InventoryFormTemplateObject, InventoryFormTemplates } from './InventoryFormTemplates' import { InventoryAdvanceSearchConfig, getArrayFromInOP } from './utils' +import { useInventorySendToGTM } from './utils/useInventorySendToGTM' interface InventoryFormProps { setConfig: Dispatch> @@ -21,12 +23,18 @@ interface InventoryFormProps { export const InventoryForm = ({ searchCrit, kind, setKind, config, setConfig }: InventoryFormProps) => { const { selectedWorkspace } = useUserProfile() - const { data: originalStartData } = useQuery({ + const sendToGTM = useInventorySendToGTM() + const { data: originalStartData, error } = useQuery({ queryKey: ['workspace-inventory-search-start', selectedWorkspace?.id], queryFn: getWorkspaceInventorySearchStartQuery, throwOnError: false, enabled: !!selectedWorkspace?.id, }) + useEffect(() => { + if (error) { + sendToGTM('getWorkspaceInventorySearchStartQuery', false, error as AxiosError, '') + } + }, [error, sendToGTM]) const startData = useMemo(() => originalStartData ?? { accounts: [], kinds: [], regions: [], severity: [] }, [originalStartData]) const processedStartData = useMemo(() => { const clouds: string[] = [] diff --git a/src/pages/panel/inventory/InventoryFormFilterRowProperty.tsx b/src/pages/panel/inventory/InventoryFormFilterRowProperty.tsx index 75f162cc..e6842e62 100644 --- a/src/pages/panel/inventory/InventoryFormFilterRowProperty.tsx +++ b/src/pages/panel/inventory/InventoryFormFilterRowProperty.tsx @@ -12,6 +12,7 @@ import { } from '@mui/material' import { useInfiniteQuery } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' +import { AxiosError } from 'axios' import { ChangeEvent, HTMLAttributes, KeyboardEvent, UIEvent as ReactUIEvent, useEffect, useMemo, useRef, useState } from 'react' import { useUserProfile } from 'src/core/auth' import { OPType, defaultProperties, kindDurationTypes, kindSimpleTypes } from 'src/pages/panel/shared/constants' @@ -20,6 +21,7 @@ import { isValidProp } from 'src/pages/panel/shared/utils' import { panelUI } from 'src/shared/constants' import { ResourceComplexKindSimpleTypeDefinitions } from 'src/shared/types/server' import { getCustomedWorkspaceInventoryPropertyAttributesQuery } from './utils' +import { useInventorySendToGTM } from './utils/useInventorySendToGTM' interface InventoryFormFilterRowPropertyProps { selectedKind: string | null @@ -79,6 +81,7 @@ export const InventoryFormFilterRowProperty = ({ selectedKind, defaultValue, kin throwOnError: false, enabled: !!selectedWorkspace?.id && !!kinds.length && isDictionary, }) + const kindsStr = JSON.stringify(kinds) const pathComplete = useInfiniteQuery({ queryKey: [ 'workspace-inventory-property-path-complete-query', @@ -86,7 +89,7 @@ export const InventoryFormFilterRowProperty = ({ selectedKind, defaultValue, kin isDefaultItemSelected ? '' : debouncedPath, !fqn || isDefaultItemSelected ? '' : debouncedProp, selectedKind, - JSON.stringify(kinds), + kindsStr, ] as const, initialPageParam: { limit: ITEMS_PER_PAGE, @@ -98,10 +101,48 @@ export const InventoryFormFilterRowProperty = ({ selectedKind, defaultValue, kin throwOnError: false, enabled: !!selectedWorkspace?.id && !!kinds.length && !isDictionary, }) - const { data = null, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = isDictionary ? propertyAttributes : pathComplete + const { data = null, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, error } = isDictionary ? propertyAttributes : pathComplete const flatData = useMemo(() => (data?.pages.flat().filter((i) => i) as Exclude['pages'][number]) ?? null, [data]) const highlightedOptionRef = useRef[number] | null>(null) + const sendToGTM = useInventorySendToGTM() + useEffect(() => { + if (error) { + if (isDictionary) { + const prop = + value === `${debouncedPath}.${debouncedProp}` || value === `${debouncedPath}.${debouncedProp.replace(/\./g, '․')}` + ? '' + : debouncedProp + const path = debouncedPath + sendToGTM('getCustomedWorkspaceInventoryPropertyAttributesQuery', false, error as AxiosError, { + workspaceId: selectedWorkspace?.id, + prop: `${path.split('.').slice(-1)[0]}${prop ? `=~"${prop.replace(/․/g, '.')}"` : ''}` ? '' : debouncedProp, + query: selectedKind ? `is(${selectedKind})` : 'all', + }) + } else { + sendToGTM('getCustomedWorkspaceInventoryPropertyAttributesQuery', false, error as AxiosError, { + workspaceId: selectedWorkspace?.id, + path: isDefaultItemSelected ? '' : debouncedPath, + prop: !fqn || isDefaultItemSelected ? '' : debouncedProp, + kinds: selectedKind ? [selectedKind] : (JSON.parse(kindsStr) as string[]), + fuzzy: true, + }) + } + } + }, [ + error, + sendToGTM, + debouncedPath, + debouncedProp, + isDictionary, + value, + selectedWorkspace?.id, + selectedKind, + isDefaultItemSelected, + fqn, + kindsStr, + ]) + useEffect(() => { if (prevPropIndex.current > propIndex) { prevFqn.current = 'object' diff --git a/src/pages/panel/inventory/InventoryFormFilterRowStringValue.tsx b/src/pages/panel/inventory/InventoryFormFilterRowStringValue.tsx index a3ec5812..d3d6f4a4 100644 --- a/src/pages/panel/inventory/InventoryFormFilterRowStringValue.tsx +++ b/src/pages/panel/inventory/InventoryFormFilterRowStringValue.tsx @@ -2,12 +2,14 @@ import { t } from '@lingui/macro' import { Autocomplete, AutocompleteProps, CircularProgress, TextField, TypographyProps } from '@mui/material' import { useInfiniteQuery } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' -import { ChangeEvent, ReactNode, UIEvent as ReactUIEvent, useMemo, useRef, useState } from 'react' +import { AxiosError } from 'axios' +import { ChangeEvent, ReactNode, UIEvent as ReactUIEvent, useEffect, useMemo, useRef, useState } from 'react' import { useUserProfile } from 'src/core/auth' import { getWorkspaceInventoryPropertyValuesQuery } from 'src/pages/panel/shared/queries' import { panelUI } from 'src/shared/constants' import { ListboxComponent } from 'src/shared/react-window' import { AutoCompleteValue } from 'src/shared/types/shared' +import { useInventorySendToGTM } from './utils/useInventorySendToGTM' const ITEMS_PER_PAGE = 50 @@ -46,6 +48,7 @@ export function InventoryFormFilterRowStringValue { + if (error) { + sendToGTM('getWorkspaceInventoryPropertyValuesQuery', false, error as AxiosError, { + workspaceId: selectedWorkspace?.id, + query: + debouncedTyped && + (!slectedTyped.current || slectedTyped.current !== debouncedTyped) && + (!value || (Array.isArray(value) ? !value.find((i) => i.label === debouncedTyped) : value.label !== debouncedTyped)) + ? `${searchCrit} and ${propertyName} ~ ".*${debouncedTyped}.*"` + : searchCrit, + prop: propertyName, + }) + } + }, [debouncedTyped, error, propertyName, searchCrit, selectedWorkspace?.id, sendToGTM, value]) const flatData = useMemo( () => data?.pages diff --git a/src/pages/panel/inventory/InventoryTable.tsx b/src/pages/panel/inventory/InventoryTable.tsx index 38531f2a..8189c493 100644 --- a/src/pages/panel/inventory/InventoryTable.tsx +++ b/src/pages/panel/inventory/InventoryTable.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useUserProfile } from 'src/core/auth' import { getWorkspaceInventorySearchTableQuery } from 'src/pages/panel/shared/queries' +import { useGTMDispatch } from 'src/shared/google-tag-manager' import { TablePagination, TableViewPage } from 'src/shared/layouts/panel-layout' import { LoadingSuspenseFallback } from 'src/shared/loading' import { @@ -27,7 +28,7 @@ export const InventoryTable = ({ searchCrit, history }: InventoryTableProps) => const [dataCount, setDataCount] = useState(-1) const [page, setPage] = useState(0) const [rowsPerPage, setRowsPerPage] = useState(10) - const { selectedWorkspace } = useUserProfile() + const { selectedWorkspace, isAuthenticated } = useUserProfile() const [rows, setRows] = useState([]) const [columns, setColumns] = useState([]) const initializedRef = useRef(false) @@ -44,6 +45,7 @@ export const InventoryTable = ({ searchCrit, history }: InventoryTableProps) => queryFn: getWorkspaceInventorySearchTableQuery, enabled: !!selectedWorkspace?.id, }) + const sendToGTM = useGTMDispatch() const [data, totalCount] = serverData ?? [[{ columns: [] }] as GetWorkspaceInventorySearchTableResponse, -1] const [selectedRow, setSelectedRow] = useState() @@ -61,6 +63,15 @@ export const InventoryTable = ({ searchCrit, history }: InventoryTableProps) => initializedRef.current = true }, [searchCrit]) + useEffect(() => { + sendToGTM({ + event: 'inventory-search', + authorized: isAuthenticated ?? false, + q: searchCrit, + workspaceId: selectedWorkspace?.id ?? 'unknown', + }) + }, [isAuthenticated, searchCrit, selectedWorkspace?.id, sendToGTM]) + useEffect(() => { if (!isLoading) { const [{ columns: newColumns }, ...newRows] = data ?? [{ columns: [] }] diff --git a/src/pages/panel/inventory/InventoryTagAutoComplete.tsx b/src/pages/panel/inventory/InventoryTagAutoComplete.tsx index cec2f7f8..29fffa63 100644 --- a/src/pages/panel/inventory/InventoryTagAutoComplete.tsx +++ b/src/pages/panel/inventory/InventoryTagAutoComplete.tsx @@ -2,11 +2,13 @@ import { Trans } from '@lingui/macro' import { Autocomplete, CircularProgress, TextField } from '@mui/material' import { useInfiniteQuery } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' -import { ReactNode, UIEvent as ReactUIEvent, useMemo, useState } from 'react' +import { AxiosError } from 'axios' +import { ReactNode, UIEvent as ReactUIEvent, useEffect, useMemo, useState } from 'react' import { useUserProfile } from 'src/core/auth' import { getWorkspaceInventoryPropertyAttributesQuery } from 'src/pages/panel/shared/queries' import { panelUI } from 'src/shared/constants' import { ListboxComponent } from 'src/shared/react-window' +import { useInventorySendToGTM } from './utils/useInventorySendToGTM' interface InventoryTagAutoCompleteProps { searchCrit: string @@ -21,6 +23,7 @@ export const InventoryTagAutoComplete = ({ searchCrit, setSelectedTag }: Invento const { selectedWorkspace } = useUserProfile() const { data = null, + error, isLoading, fetchNextPage, hasNextPage, @@ -42,6 +45,16 @@ export const InventoryTagAutoComplete = ({ searchCrit, setSelectedTag }: Invento throwOnError: false, enabled: !!selectedWorkspace?.id, }) + const sendToGTM = useInventorySendToGTM() + useEffect(() => { + if (error) { + sendToGTM('getWorkspaceInventoryPropertyAttributesQuery', false, error as AxiosError, { + workspaceId: selectedWorkspace?.id, + query: searchCrit.startsWith('is') ? searchCrit.split(' ')[0] : 'all', + prop: `tags${debouncedTyped ? `=~${debouncedTyped}` : ''}`, + }) + } + }, [debouncedTyped, error, searchCrit, selectedWorkspace?.id, sendToGTM]) const flatData = useMemo(() => (data?.pages.flat().filter((i) => i) as Exclude['pages'][number]) ?? null, [data]) const handleScroll = (e: ReactUIEvent) => { if ( @@ -61,7 +74,7 @@ export const InventoryTagAutoComplete = ({ searchCrit, setSelectedTag }: Invento onChange={(_, value) => setSelectedTag(value ?? '')} getOptionLabel={(option) => option ?? ''} filterOptions={(option) => option} - options={flatData ?? []} + options={isLoading ? [] : flatData ?? []} ListboxComponent={ListboxComponent} ListboxProps={{ onScroll: handleScroll, diff --git a/src/pages/panel/inventory/ResourceDetail.tsx b/src/pages/panel/inventory/ResourceDetail.tsx index b59a7f31..77397d6f 100644 --- a/src/pages/panel/inventory/ResourceDetail.tsx +++ b/src/pages/panel/inventory/ResourceDetail.tsx @@ -18,6 +18,7 @@ import { styled, } from '@mui/material' import { useQuery } from '@tanstack/react-query' +import { AxiosError } from 'axios' import { Fragment, ReactNode, useEffect, useState } from 'react' import { useUserProfile } from 'src/core/auth' import { FailedChecks } from 'src/pages/panel/shared/failed-checks' @@ -27,6 +28,7 @@ import { GetWorkspaceInventorySearchTableRow } from 'src/shared/types/server' import { diffDateTimeToDuration, iso8601DurationToString } from 'src/shared/utils/parseDuration' import { YamlHighlighter } from 'src/shared/yaml-highlighter' import { stringify } from 'yaml' +import { useInventorySendToGTM } from './utils/useInventorySendToGTM' interface ResourceDetailProps { detail: GetWorkspaceInventorySearchTableRow | undefined @@ -110,13 +112,20 @@ const GridItem = ({ export const ResourceDetail = ({ detail, onClose }: ResourceDetailProps) => { const { selectedWorkspace } = useUserProfile() - const { data, isLoading } = useQuery({ + const sendToGTM = useInventorySendToGTM() + const { data, isLoading, error } = useQuery({ queryKey: ['workspace-inventory-node', selectedWorkspace?.id, detail?.id], queryFn: getWorkspaceInventoryNodeQuery, throwOnError: false, }) const [selectedRow, setSelectedRow] = useState(detail) + useEffect(() => { + if (error) { + sendToGTM('getWorkspaceInventoryNodeQuery', false, error as AxiosError, detail?.id, detail?.id) + } + }, [detail?.id, error, sendToGTM]) + useEffect(() => { if (detail) { setSelectedRow(detail) diff --git a/src/pages/panel/inventory/utils/useInventorySendToGTM.ts b/src/pages/panel/inventory/utils/useInventorySendToGTM.ts new file mode 100644 index 00000000..8f68b224 --- /dev/null +++ b/src/pages/panel/inventory/utils/useInventorySendToGTM.ts @@ -0,0 +1,64 @@ +import { AxiosError } from 'axios' +import { useCallback } from 'react' +import { useUserProfile } from 'src/core/auth' +import { endPoints } from 'src/shared/constants' +import { useGTMDispatch } from 'src/shared/google-tag-manager' +import { jsonToStr } from 'src/shared/utils/jsonToStr' + +type queryFnStr = + | 'getWorkspaceInventorySearchStartQuery' + | 'getWorkspaceInventoryNodeQuery' + | 'getCustomedWorkspaceInventoryPropertyAttributesQuery' + | 'getWorkspaceInventoryPropertyAttributesQuery' + | 'getWorkspaceInventoryPropertyPathCompleteQuery' + | 'getWorkspaceInventoryPropertyValuesQuery' + +const queryFnStrToApi = (queryFn: queryFnStr, workspaceId: string, id?: string) => { + switch (queryFn) { + case 'getWorkspaceInventorySearchStartQuery': + return endPoints.workspaces.workspace(workspaceId).inventory.search.start + + case 'getWorkspaceInventoryNodeQuery': + return endPoints.workspaces.workspace(workspaceId).inventory.node(id ?? 'unknown') + + case 'getWorkspaceInventoryPropertyAttributesQuery': + case 'getCustomedWorkspaceInventoryPropertyAttributesQuery': + return endPoints.workspaces.workspace(workspaceId).inventory.property.attributes + + case 'getWorkspaceInventoryPropertyPathCompleteQuery': + return endPoints.workspaces.workspace(workspaceId).inventory.property.path.complete + + case 'getWorkspaceInventoryPropertyValuesQuery': + return endPoints.workspaces.workspace(workspaceId).inventory.property.values + + default: + return '' + } +} + +export const useInventorySendToGTM = () => { + const { isAuthenticated, selectedWorkspace: { id: workspaceId } = { id: 'unknown' } } = useUserProfile() + const sendToGTM = useGTMDispatch() + const handleSendToGTM = useCallback( + (queryFn: queryFnStr, isAdvanceSearch: boolean, error: AxiosError, params: unknown, id?: string) => { + const { message, name, response, stack, status } = error + sendToGTM({ + event: 'inventory-error', + api: queryFnStrToApi(queryFn, workspaceId, id), + authorized: isAuthenticated ?? false, + isAdvanceSearch, + params, + name: jsonToStr(name), + stack: jsonToStr(stack), + message: jsonToStr(message), + request: jsonToStr(error.request as unknown), + response: jsonToStr(response), + status: jsonToStr(status), + workspaceId, + }) + }, + [isAuthenticated, sendToGTM, workspaceId], + ) + + return handleSendToGTM +} diff --git a/src/shared/absolute-navigate/AbsoluteNavigateProvider.tsx b/src/shared/absolute-navigate/AbsoluteNavigateProvider.tsx index 985a13bb..1f6544b7 100644 --- a/src/shared/absolute-navigate/AbsoluteNavigateProvider.tsx +++ b/src/shared/absolute-navigate/AbsoluteNavigateProvider.tsx @@ -1,8 +1,34 @@ -import { PropsWithChildren } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLingui } from '@lingui/react' +import { PropsWithChildren, useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { useThemeMode } from 'src/core/theme' +import { useGTMDispatch } from 'src/shared/google-tag-manager' +import { getAuthData } from 'src/shared/utils/localstorage' import { AbsoluteNavigateInnerProvider } from './AbsoluteNavigateInnerProvider' export const AbsoluteNavigateProvider = ({ children }: PropsWithChildren) => { const navigate = useNavigate() + const location = useLocation() + const { mode } = useThemeMode() + const { + i18n: { locale }, + } = useLingui() + const sendToGTM = useGTMDispatch() + + useEffect(() => { + const { isAuthenticated, selectedWorkspace } = getAuthData() || {} + sendToGTM({ + event: 'page', + hash: location.hash, + language: locale.replace('-', '_'), + path: location.pathname, + search: location.search, + state: JSON.stringify(location.state), + workspaceId: selectedWorkspace || 'unknown', + darkMode: mode === 'dark', + authorized: isAuthenticated || false, + }) + }, [location, locale, sendToGTM, mode]) + return {children} } diff --git a/src/shared/constants/env.ts b/src/shared/constants/env.ts index 1a0dcea3..192313dd 100644 --- a/src/shared/constants/env.ts +++ b/src/shared/constants/env.ts @@ -11,7 +11,7 @@ const envToNumber = (value?: string) => { const defaultOrigin = window.location.origin const wsOrigin = defaultOrigin.replace('http', 'ws') -export const env = { +const env = { apiUrl: import.meta.env.VITE_USE_PROXY === 'true' ? defaultOrigin : import.meta.env.VITE_SERVER ?? defaultOrigin, wsUrl: import.meta.env.VITE_USE_PROXY === 'true' @@ -20,4 +20,7 @@ export const env = { retryCount: envToNumber(import.meta.env.VITE_NETWORK_RETRY_COUNT) ?? 5, webSocketRetryTimeout: envToNumber(import.meta.env.VITE_WEBSOCKET_RETRY_TIMEOUT) ?? 5_000, discordUrl: import.meta.env.VITE_DISCORD_URL ?? '#', + gtmId: undefined as string | undefined, } + +export { env } diff --git a/src/shared/error-boundary-fallback/ErrorBoundaryFallback.tsx b/src/shared/error-boundary-fallback/ErrorBoundaryFallback.tsx index a6392cf3..cfbc0970 100644 --- a/src/shared/error-boundary-fallback/ErrorBoundaryFallback.tsx +++ b/src/shared/error-boundary-fallback/ErrorBoundaryFallback.tsx @@ -4,8 +4,12 @@ import { Button, Divider, Link, Modal, Stack, Typography, styled } from '@mui/ma import { useEffect } from 'react' import { FallbackProps } from 'react-error-boundary' import { DiscrodIcon } from 'src/assets/icons' +import { useUserProfile } from 'src/core/auth' import { useAbsoluteNavigate } from 'src/shared/absolute-navigate' import { env } from 'src/shared/constants' +import { useGTMDispatch } from 'src/shared/google-tag-manager' +import { jsonToStr } from 'src/shared/utils/jsonToStr' +import { getAuthData } from 'src/shared/utils/localstorage' const ModalContent = styled(Stack)(({ theme }) => ({ position: 'absolute', @@ -21,11 +25,45 @@ const ModalContent = styled(Stack)(({ theme }) => ({ })) export const ErrorBoundaryFallback = ({ error, resetErrorBoundary }: FallbackProps) => { + const sendToGTM = useGTMDispatch() const navigate = useAbsoluteNavigate() + const { selectedWorkspace, isAuthenticated } = useUserProfile(false) useEffect(() => { - console.error(error) - }, [error]) + if ('response' in error) { + const { response, name, message, cause, status, stack, config, code, toJSON } = error + const request = error.request as unknown + sendToGTM({ + event: 'network-error', + api: response?.config.url || 'unknown', + responseData: jsonToStr(response?.data) || '', + responseHeader: jsonToStr(response?.data) || '', + responseStatus: jsonToStr(response?.status) || '', + response: jsonToStr(response), + request: jsonToStr(request), + name: jsonToStr(name), + message: jsonToStr(message), + cause: jsonToStr(cause), + status: jsonToStr(status), + stack: jsonToStr(stack), + config: jsonToStr(config), + code: jsonToStr(code), + rest: jsonToStr(toJSON()), + workspaceId: selectedWorkspace?.id || getAuthData()?.selectedWorkspace || 'unknown', + authorized: (isAuthenticated === undefined ? getAuthData()?.isAuthenticated : isAuthenticated) ?? false, + }) + } else { + const { message, name, stack } = error as Error + sendToGTM({ + event: 'error', + message: jsonToStr(message), + name: jsonToStr(name), + stack: jsonToStr(stack), + workspaceId: selectedWorkspace?.id || getAuthData()?.selectedWorkspace || 'unknown', + authorized: (isAuthenticated === undefined ? getAuthData()?.isAuthenticated : isAuthenticated) ?? false, + }) + } + }, [error, isAuthenticated, selectedWorkspace?.id, sendToGTM]) return ( diff --git a/src/shared/google-tag-manager/GoogleTagManager.tsx b/src/shared/google-tag-manager/GoogleTagManager.tsx new file mode 100644 index 00000000..e1ab1db7 --- /dev/null +++ b/src/shared/google-tag-manager/GoogleTagManager.tsx @@ -0,0 +1,61 @@ +import { ReactNode, createContext, useEffect, useReducer } from 'react' + +import { DataEventTypes, SnippetsParams } from './GoogleTagManagerTypes' +import { initGTM } from './initGTM' +import { sendToGTM } from './sendToGTM' + +declare global { + interface Window { + dataLayer: unknown[] | undefined + [key: string]: unknown + } +} + +/** + * The shape of the context provider + */ +type GTMHookProviderProps = { state?: SnippetsParams; children: ReactNode } + +/** + * The initial state + */ +const initialState: SnippetsParams = { + dataLayer: undefined, + dataLayerName: 'dataLayer', + environment: undefined, + nonce: undefined, + id: '', + injectScript: true, +} + +/** + * The context + */ +export const GTMContext = createContext(initialState) +export const GTMContextDispatch = createContext<((data: DataEventTypes) => void) | undefined>(undefined) + +function dataReducer(state: SnippetsParams, data: DataEventTypes) { + sendToGTM({ data, dataLayerName: state?.dataLayerName ?? '' }) + return state +} + +/** + * The Google Tag Manager Provider + */ +export const GTMProvider = ({ state, children }: GTMHookProviderProps) => { + const [store, dispatch] = useReducer(dataReducer, { ...initialState, ...state }) + + useEffect(() => { + if (!state || state.injectScript == false) return + const mergedState = { ...store, ...state } + + initGTM(mergedState) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(state)]) + + return ( + + {}}>{children} + + ) +} diff --git a/src/shared/google-tag-manager/GoogleTagManagerTypes.ts b/src/shared/google-tag-manager/GoogleTagManagerTypes.ts new file mode 100644 index 00000000..258788b9 --- /dev/null +++ b/src/shared/google-tag-manager/GoogleTagManagerTypes.ts @@ -0,0 +1,147 @@ +/** + * The shape of the dataLayer + */ +export type DataLayerType = { + dataLayer: DataEventTypes[] | undefined + dataLayerName: string +} + +/** + * The shape of the GTM Snippets + */ +export type SnippetsType = { + gtmDataLayer: string + gtmIframe: string + gtmScript: string +} + +/** + * The variables required to use a GTM custom environment + */ +export type CustomEnvironmentParams = { + /** + * For the `gtm_auth` parameter. + */ + gtm_auth: string + + /** + * For the `gtm_preview` parameter. + */ + gtm_preview: string +} + +/** + * The shape of the GTM Snippets params + */ +export type SnippetsParams = { + dataLayer?: Pick['dataLayer'] + dataLayerName?: Pick['dataLayerName'] + environment?: CustomEnvironmentParams + nonce?: string + id: string + injectScript?: boolean + /** Defaults to https://www.googletagmanager.com */ + customDomain?: string + /** Defaults to gtm.js */ + customScriptName?: string +} + +/** + * The shape of the setupGTM function + */ +export type SetupGTMParams = { + getDataLayerScript: () => HTMLElement + getNoScript: () => HTMLElement + getScript: () => HTMLElement +} + +type DataEventCommonError = { + name: string + message: string + stack: string + workspaceId: string + authorized: boolean +} + +type DataEventPage = { + event: 'page' + language: string + path: string + search: string + hash: string + state: string + workspaceId: string + darkMode: boolean + authorized: boolean +} + +type DataEventError = DataEventCommonError & { + event: 'error' +} + +type DataEventSocketError = DataEventCommonError & { + event: 'socket-error' + state: 'on-message' | 'send-message' | 'on-close' | 'on-open' + params: string + api: string +} + +type DataEventNetworkError = DataEventCommonError & { + event: 'network-error' + api: string + responseData: string + responseHeader: string + responseStatus: string + response: string + request: string + cause: string + status: string + config: string + code: string + rest: string +} + +type DataEventInventorySearch = { + event: 'inventory-search' + q: string + workspaceId: string + authorized: boolean +} + +type DataEventInventoryError = DataEventCommonError & { + event: 'inventory-error' + isAdvanceSearch: boolean + api: string + params: unknown + response: string + request: string + status: number | string +} + +type DataEventLogin = { + event: 'login' + username: string +} + +type DataEventSignup = { + event: 'signup' + username: string +} + +export type DataEventTypes = + | DataEventPage + | DataEventError + | DataEventSocketError + | DataEventNetworkError + | DataEventInventorySearch + | DataEventInventoryError + | DataEventLogin + | DataEventSignup + +/** + * The shape of the sendToGtm function + */ +export type SendToGTMParams = { + dataLayerName: keyof typeof window + data: DataEventTypes +} diff --git a/src/shared/google-tag-manager/index.ts b/src/shared/google-tag-manager/index.ts new file mode 100644 index 00000000..e7662000 --- /dev/null +++ b/src/shared/google-tag-manager/index.ts @@ -0,0 +1,3 @@ +export { GTMProvider } from './GoogleTagManager' +export { sendToGTM } from './sendToGTM' +export { useGTMDispatch } from './useGTMDispatch' diff --git a/src/shared/google-tag-manager/initGTM.ts b/src/shared/google-tag-manager/initGTM.ts new file mode 100644 index 00000000..44362879 --- /dev/null +++ b/src/shared/google-tag-manager/initGTM.ts @@ -0,0 +1,54 @@ +import { SetupGTMParams, SnippetsParams } from './GoogleTagManagerTypes' +import { getDataLayerSnippet, getGTMScript, getIframeSnippet } from './utils' + +const setupGTM = (params: SnippetsParams) => { + const getDataLayerScript = (): HTMLElement => { + const dataLayerScript = window.document.createElement('script') + if (params.nonce) { + dataLayerScript.setAttribute('nonce', params.nonce) + } + dataLayerScript.innerHTML = getDataLayerSnippet(params.dataLayer, params.dataLayerName) + return dataLayerScript + } + + const getNoScript = (): HTMLElement => { + const noScript = window.document.createElement('noscript') + noScript.innerHTML = getIframeSnippet(params.id, params.environment, params.customDomain) + return noScript + } + + const getScript = (): HTMLElement => { + const script = window.document.createElement('script') + if (params.nonce) { + script.setAttribute('nonce', params.nonce) + } + script.innerHTML = getGTMScript(params.dataLayerName, params.id, params.environment, params.customDomain, params.customScriptName) + return script + } + + return { + getDataLayerScript, + getNoScript, + getScript, + } as SetupGTMParams +} + +export const initGTM = ({ dataLayer, dataLayerName, environment, nonce, id, customDomain, customScriptName }: SnippetsParams): void => { + const gtm = setupGTM({ + dataLayer, + dataLayerName, + environment, + nonce, + id, + customDomain, + customScriptName, + }) + + const dataLayerScript = gtm.getDataLayerScript() + const script = gtm.getScript() + const noScript = gtm.getNoScript() + + window.document.head.insertBefore(dataLayerScript, window.document.head.childNodes[0]) + window.document.head.insertBefore(script, window.document.head.childNodes[1]) + window.document.body.insertBefore(noScript, window.document.body.childNodes[0]) +} diff --git a/src/shared/google-tag-manager/sendToGTM.ts b/src/shared/google-tag-manager/sendToGTM.ts new file mode 100644 index 00000000..4e407666 --- /dev/null +++ b/src/shared/google-tag-manager/sendToGTM.ts @@ -0,0 +1,16 @@ +import { SendToGTMParams } from './GoogleTagManagerTypes' + +export const sendToGTM = ({ dataLayerName, data }: SendToGTMParams): void => { + const dataLayer = window[dataLayerName] && Array.isArray(window[dataLayerName]) ? (window[dataLayerName] as unknown[]) : undefined + if (dataLayer) { + dataLayer.push(data) + } else { + console.warn(`dataLayer ${dataLayerName} does not exist`) + } + if ('gtag' in window) { + const { event, ...rest } = data + window.gtag('event', event, rest) + } else { + console.warn(`gtag does not exist in window`) + } +} diff --git a/src/shared/google-tag-manager/useGTMDispatch.ts b/src/shared/google-tag-manager/useGTMDispatch.ts new file mode 100644 index 00000000..4dab99b7 --- /dev/null +++ b/src/shared/google-tag-manager/useGTMDispatch.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' +import { GTMContextDispatch } from './GoogleTagManager' + +export const useGTMDispatch = () => { + const context = useContext(GTMContextDispatch) + if (context === undefined) { + throw new Error('dispatchGTMEvent must be used within a GTMProvider') + } + + return context +} diff --git a/src/shared/google-tag-manager/utils.ts b/src/shared/google-tag-manager/utils.ts new file mode 100644 index 00000000..9c1764ca --- /dev/null +++ b/src/shared/google-tag-manager/utils.ts @@ -0,0 +1,63 @@ +import { CustomEnvironmentParams, DataLayerType, SnippetsParams, SnippetsType } from './GoogleTagManagerTypes' + +export const DEFAULT_DOMAIN = 'https://www.googletagmanager.com' +export const DEFAULT_SCRIPT_NAME = 'gtag/js' + +/** + * Function to get and set dataLayer + * @param dataLayer - The dataLayer + * @param dataLayerName - The dataLayer name + */ +export const getDataLayerSnippet = ( + dataLayer: Pick['dataLayer'], + dataLayerName: Pick['dataLayerName'] = 'dataLayer', +): Pick['gtmDataLayer'] => + `window.${dataLayerName} = window.${dataLayerName} || [];` + + (dataLayer ? `window.${dataLayerName}.push(${JSON.stringify(dataLayer)})` : '') + +/** + * Function to get the Iframe snippet + * @param environment - The parameters to use a custom environment + * @param customDomain - Custom domain for gtm + * @param id - The id of the container + */ +export const getIframeSnippet = ( + id: Pick['id'], + environment?: CustomEnvironmentParams, + customDomain: SnippetsParams['customDomain'] = DEFAULT_DOMAIN, +) => { + let params = `` + if (environment) { + const { gtm_auth, gtm_preview } = environment + params = `>m_auth=${gtm_auth}>m_preview=${gtm_preview}>m_cookies_win=x` + } + return `` +} + +/** + * Function to get the GTM script + * @param dataLayerName - The name of the dataLayer + * @param customDomain - Custom domain for gtm + * @param customScriptName - Custom script file name for gtm + * @param environment - The parameters to use a custom environment + * @param id - The id of the container + */ +export const getGTMScript = ( + dataLayerName: Pick['dataLayerName'], + id: Pick['id'], + environment?: CustomEnvironmentParams, + customDomain: SnippetsParams['customDomain'] = DEFAULT_DOMAIN, + customScriptName: SnippetsParams['customScriptName'] = DEFAULT_SCRIPT_NAME, +) => { + let params = `` + if (environment) { + const { gtm_auth, gtm_preview } = environment + params = `+">m_auth=${gtm_auth}>m_preview=${gtm_preview}>m_cookies_win=x"` + } + return ` + (function(w,d,s,l,i){w[l]=w[l]||[];w.gtag = function(){w[l].push(arguments);};gtag('js', new Date());gtag('config', i); + var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= + '${customDomain}/${customScriptName}?id='+i+dl${params};f.parentNode.insertBefore(j,f); + })(window,document,'script','${dataLayerName}','${id}'); + ` +} diff --git a/src/shared/types/global/gtag.d.ts b/src/shared/types/global/gtag.d.ts new file mode 100644 index 00000000..c12419bc --- /dev/null +++ b/src/shared/types/global/gtag.d.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + gtag: (name: 'event', event: string, params: unknown) => void + } +} + +export {} diff --git a/src/shared/types/global/vite-client.d.ts b/src/shared/types/global/vite-client.d.ts index 1a7c178c..0a466b58 100644 --- a/src/shared/types/global/vite-client.d.ts +++ b/src/shared/types/global/vite-client.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { readonly VITE_NETWORK_RETRY_COUNT?: string readonly VITE_WEBSOCKET_RETRY_TIMEOUT?: string readonly VITE_DISCORD_URL?: string + readonly VITE_GTM_DEV_ID?: string + readonly VITE_GTM_PROD_ID?: string } interface ImportMeta { diff --git a/src/shared/utils/Providers.tsx b/src/shared/utils/Providers.tsx index c55837b0..dbd49a14 100644 --- a/src/shared/utils/Providers.tsx +++ b/src/shared/utils/Providers.tsx @@ -5,7 +5,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { AxiosError } from 'axios' -import { PropsWithChildren, useEffect } from 'react' +import { PropsWithChildren, useEffect, useState } from 'react' import { BrowserRouter } from 'react-router-dom' import { AuthGuard } from 'src/core/auth' import { SnackbarProvider } from 'src/core/snackbar' @@ -13,7 +13,9 @@ import { Theme } from 'src/core/theme' import { AbsoluteNavigateProvider } from 'src/shared/absolute-navigate' import { env, langs } from 'src/shared/constants' import { ErrorBoundaryFallback, NetworkErrorBoundary } from 'src/shared/error-boundary-fallback' +import { GTMProvider } from 'src/shared/google-tag-manager' import { FullPageLoadingProvider } from 'src/shared/loading' +import { getEnviromentStr } from './getEnviroment' import { getLocale, setLocale } from './localstorage' const queryClient = new QueryClient({ @@ -60,25 +62,37 @@ export const InnerI18nProvider = ({ children }: PropsWithChildren) => { } export const Providers = ({ children }: PropsWithChildren) => { + const [gtmId, setGtmId] = useState() + + useEffect(() => { + getEnviromentStr() + .then((envStr) => { + setGtmId(envStr === 'prd' ? (env.gtmId = import.meta.env.VITE_GTM_PROD_ID) : (env.gtmId = import.meta.env.VITE_GTM_DEV_ID)) + }) + .catch(() => {}) + }) + return ( - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + + + {children} + + + + + + + + + + ) } diff --git a/src/shared/utils/getEnviroment.ts b/src/shared/utils/getEnviroment.ts new file mode 100644 index 00000000..37671a49 --- /dev/null +++ b/src/shared/utils/getEnviroment.ts @@ -0,0 +1,22 @@ +export const getEnviromentStr = async () => { + return new Promise<'prd' | 'dev'>((resolve) => { + const request = new window.XMLHttpRequest() + request.onreadystatechange = function () { + if (request.readyState === window.XMLHttpRequest.DONE) { + resolve( + request + .getAllResponseHeaders() + .split('\n') + .find((i) => i.startsWith('fix-environment')) + ?.substring(17) + .startsWith('prd') + ? 'prd' + : 'dev', + ) + } + } + + request.open('HEAD', window.document.location.href, true) + request.send(null) + }) +} diff --git a/src/shared/utils/jsonToStr.ts b/src/shared/utils/jsonToStr.ts new file mode 100644 index 00000000..3226de61 --- /dev/null +++ b/src/shared/utils/jsonToStr.ts @@ -0,0 +1 @@ +export const jsonToStr = (param: unknown) => (typeof param === 'string' ? param : JSON.stringify(param)) || ''