diff --git a/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.spec.tsx b/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.spec.tsx
index 9baa4c9a23f..7cbb5130c32 100644
--- a/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.spec.tsx
+++ b/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.spec.tsx
@@ -55,6 +55,21 @@ describe('NotificationSwitch', () => {
)
})
+ it('renders without Tooltip when name is not provided', async () => {
+ render(
+
+
+
+
+
+ )
+
+ fireEvent.mouseOver(screen.getByRole('checkbox'))
+ await waitFor(() =>
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ )
+ })
+
it('does not update event email notifications when disabled', async () => {
render(
diff --git a/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.tsx b/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.tsx
index 2196bb9fd4f..103ed3f66fb 100644
--- a/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.tsx
+++ b/apps/journeys-admin/src/components/AccessDialog/NotificationSwitch/NotificationSwitch.tsx
@@ -60,16 +60,22 @@ export function NotificationSwitch({
}
}
+ const switchElement = (
+
+
+
+ )
+
+ if (name == null) return switchElement
+
return (
-
-
-
+ {switchElement}
)
}
diff --git a/apps/journeys-admin/src/components/Google/GoogleCreateIntegration/GoogleCreateIntegration.tsx b/apps/journeys-admin/src/components/Google/GoogleCreateIntegration/GoogleCreateIntegration.tsx
index bb82688da2f..695ba3c2521 100644
--- a/apps/journeys-admin/src/components/Google/GoogleCreateIntegration/GoogleCreateIntegration.tsx
+++ b/apps/journeys-admin/src/components/Google/GoogleCreateIntegration/GoogleCreateIntegration.tsx
@@ -112,6 +112,11 @@ export function GoogleCreateIntegration(): ReactElement {
preventDuplicate: true
})
}
+ const fallbackPath =
+ returnTo != null && returnTo !== ''
+ ? returnTo
+ : `/teams/${teamId}/integrations`
+ await router.push(fallbackPath)
} finally {
setLoading(false)
}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/AddSyncFormDialog/AddSyncFormDialog.tsx b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/AddSyncFormDialog/AddSyncFormDialog.tsx
new file mode 100644
index 00000000000..2a046d2646a
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/AddSyncFormDialog/AddSyncFormDialog.tsx
@@ -0,0 +1,479 @@
+import FolderIcon from '@mui/icons-material/Folder'
+import Box from '@mui/material/Box'
+import Button from '@mui/material/Button'
+import FormControl from '@mui/material/FormControl'
+import MenuItem from '@mui/material/MenuItem'
+import Select from '@mui/material/Select'
+import TextField from '@mui/material/TextField'
+import Typography from '@mui/material/Typography'
+import { Form, Formik, FormikHelpers } from 'formik'
+import { useTranslation } from 'next-i18next'
+import { ReactElement } from 'react'
+import { object, string } from 'yup'
+
+import { Dialog } from '@core/shared/ui/Dialog'
+import ChevronDown from '@core/shared/ui/icons/ChevronDown'
+
+import { getGoogleOAuthUrl } from '../../../../../libs/googleOAuthUrl'
+
+import { SyncFormValues } from '../types'
+
+interface Integration {
+ __typename: string
+ id: string
+ accountEmail?: string | null
+}
+
+interface AddSyncFormDialogProps {
+ open: boolean
+ onClose: () => void
+ pickerActive: boolean
+ integrations: Integration[]
+ teamId: string | undefined
+ journeyTitle: string | undefined
+ sheetsLoading: boolean
+ onSubmit: (
+ values: SyncFormValues,
+ actions: FormikHelpers
+ ) => Promise
+ onOpenDrivePicker: (
+ mode: 'folder' | 'sheet',
+ integrationId: string | undefined,
+ setFieldValue: (field: string, value: unknown) => void
+ ) => Promise
+ routerAsPath: string
+}
+
+export function AddSyncFormDialog({
+ open,
+ onClose,
+ pickerActive,
+ integrations,
+ teamId,
+ journeyTitle,
+ sheetsLoading,
+ onSubmit,
+ onOpenDrivePicker,
+ routerAsPath
+}: AddSyncFormDialogProps): ReactElement {
+ const { t } = useTranslation('apps-journeys-admin')
+
+ const googleIntegrations = integrations.filter(
+ (integration) => integration.__typename === 'IntegrationGoogle'
+ )
+
+ const validationSchema = object().shape({
+ integrationId: string().required(t('Integration account is required')),
+ sheetName: string().required(t('Sheet tab name is required')),
+ spreadsheetTitle: string().when(
+ 'googleMode',
+ (googleMode: unknown, schema) =>
+ googleMode === 'create'
+ ? schema.required(t('Sheet name is required'))
+ : schema.notRequired()
+ )
+ })
+
+ const initialValues: SyncFormValues = {
+ integrationId: '',
+ googleMode: '',
+ spreadsheetTitle: '',
+ sheetName: '',
+ folderId: undefined,
+ folderName: undefined,
+ existingSpreadsheetId: undefined,
+ existingSpreadsheetName: undefined
+ }
+
+ return (
+
+ {({
+ values,
+ handleChange,
+ handleBlur,
+ handleSubmit,
+ errors,
+ touched,
+ resetForm,
+ setFieldValue
+ }) => (
+
+ )}
+
+ )
+}
+
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/AddSyncFormDialog/index.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/AddSyncFormDialog/index.ts
new file mode 100644
index 00000000000..589b6e86196
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/AddSyncFormDialog/index.ts
@@ -0,0 +1 @@
+export { AddSyncFormDialog } from './AddSyncFormDialog'
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/DeleteSyncDialog/DeleteSyncDialog.tsx b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/DeleteSyncDialog/DeleteSyncDialog.tsx
new file mode 100644
index 00000000000..595d705263f
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/DeleteSyncDialog/DeleteSyncDialog.tsx
@@ -0,0 +1,74 @@
+import Box from '@mui/material/Box'
+import Button from '@mui/material/Button'
+import Typography from '@mui/material/Typography'
+import { useTranslation } from 'next-i18next'
+import { ReactElement } from 'react'
+
+import { Dialog } from '@core/shared/ui/Dialog'
+
+interface DeleteSyncDialogProps {
+ syncIdPendingDelete: string | null
+ deletingSyncId: string | null
+ onClose: () => void
+ onDelete: (syncId: string) => void
+}
+
+export function DeleteSyncDialog({
+ syncIdPendingDelete,
+ deletingSyncId,
+ onClose,
+ onDelete
+}: DeleteSyncDialogProps): ReactElement {
+ const { t } = useTranslation('apps-journeys-admin')
+
+ return (
+
+ )
+}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/DeleteSyncDialog/index.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/DeleteSyncDialog/index.ts
new file mode 100644
index 00000000000..b3e9421e150
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/DeleteSyncDialog/index.ts
@@ -0,0 +1 @@
+export { DeleteSyncDialog } from './DeleteSyncDialog'
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.spec.tsx b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.spec.tsx
index 04ef4603112..7013e46965b 100644
--- a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.spec.tsx
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.spec.tsx
@@ -9,6 +9,13 @@ jest.mock('next-i18next', () => ({
})
}))
+jest.mock('next-firebase-auth', () => ({
+ useUser: () => ({
+ clientInitialized: true,
+ id: 'user1'
+ })
+}))
+
const mockEnqueueSnackbar = jest.fn()
jest.mock('notistack', () => ({
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.tsx b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.tsx
index 213c51a0f2b..59267943a61 100644
--- a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.tsx
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog.tsx
@@ -1,146 +1,38 @@
-import { gql, useLazyQuery, useMutation, useQuery } from '@apollo/client'
-import FolderIcon from '@mui/icons-material/Folder'
-import LaunchIcon from '@mui/icons-material/Launch'
-import NorthEastIcon from '@mui/icons-material/NorthEast'
-import RefreshIcon from '@mui/icons-material/Refresh'
+import { useMutation, useQuery } from '@apollo/client'
import Accordion from '@mui/material/Accordion'
import AccordionDetails from '@mui/material/AccordionDetails'
import AccordionSummary from '@mui/material/AccordionSummary'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
-import Chip from '@mui/material/Chip'
import CircularProgress from '@mui/material/CircularProgress'
-import FormControl from '@mui/material/FormControl'
-import IconButton from '@mui/material/IconButton'
-import Link from '@mui/material/Link'
-import MenuItem from '@mui/material/MenuItem'
-import Select from '@mui/material/Select'
import { useTheme } from '@mui/material/styles'
-import Table from '@mui/material/Table'
-import TableBody from '@mui/material/TableBody'
-import TableCell from '@mui/material/TableCell'
-import TableContainer from '@mui/material/TableContainer'
-import TableHead from '@mui/material/TableHead'
-import TableRow from '@mui/material/TableRow'
-import TextField from '@mui/material/TextField'
-import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
-import { format } from 'date-fns'
-import { Form, Formik, FormikHelpers, FormikValues } from 'formik'
+import { FormikHelpers } from 'formik'
import { useRouter } from 'next/router'
+import { useUser } from 'next-firebase-auth'
import { useTranslation } from 'next-i18next'
import { useSnackbar } from 'notistack'
-import { KeyboardEvent, ReactElement, useEffect, useState } from 'react'
-import { object, string } from 'yup'
+import { ReactElement, useEffect, useState } from 'react'
import { Dialog } from '@core/shared/ui/Dialog'
import ChevronDown from '@core/shared/ui/icons/ChevronDown'
import Plus2Icon from '@core/shared/ui/icons/Plus2'
-import Trash2Icon from '@core/shared/ui/icons/Trash2'
-import { getGoogleOAuthUrl } from '../../../../libs/googleOAuthUrl'
import { useIntegrationQuery } from '../../../../libs/useIntegrationQuery/useIntegrationQuery'
-const GET_JOURNEY_CREATED_AT = gql`
- query GoogleSheetsSyncDialogJourney($id: ID!) {
- journey: adminJourney(id: $id, idType: databaseId) {
- id
- createdAt
- title
- team {
- id
- }
- }
- }
-`
-
-const GET_GOOGLE_PICKER_TOKEN = gql`
- query IntegrationGooglePickerToken($integrationId: ID!) {
- integrationGooglePickerToken(integrationId: $integrationId)
- }
-`
-
-const GET_GOOGLE_SHEETS_SYNCS = gql`
- query GoogleSheetsSyncs($filter: GoogleSheetsSyncsFilter!) {
- googleSheetsSyncs(filter: $filter) {
- id
- spreadsheetId
- sheetName
- email
- deletedAt
- createdAt
- integration {
- __typename
- id
- ... on IntegrationGoogle {
- accountEmail
- }
- }
- }
- }
-`
-
-const EXPORT_TO_SHEETS = gql`
- mutation JourneyVisitorExportToGoogleSheet(
- $journeyId: ID!
- $destination: JourneyVisitorGoogleSheetDestinationInput!
- $integrationId: ID!
- $timezone: String
- ) {
- journeyVisitorExportToGoogleSheet(
- journeyId: $journeyId
- destination: $destination
- integrationId: $integrationId
- timezone: $timezone
- ) {
- spreadsheetId
- spreadsheetUrl
- sheetName
- }
- }
-`
-
-const DELETE_GOOGLE_SHEETS_SYNC = gql`
- mutation GoogleSheetsSyncDialogDelete($id: ID!) {
- googleSheetsSyncDelete(id: $id) {
- id
- }
- }
-`
-
-const BACKFILL_GOOGLE_SHEETS_SYNC = gql`
- mutation GoogleSheetsSyncDialogBackfill($id: ID!) {
- googleSheetsSyncBackfill(id: $id) {
- id
- }
- }
-`
-
-interface GoogleSheetsSyncItem {
- id: string
- spreadsheetId: string | null
- sheetName: string | null
- email: string | null
- deletedAt: string | null
- createdAt: string
- integration: {
- __typename: string
- id: string
- accountEmail?: string | null
- } | null
-}
-
-interface GoogleSheetsSyncsQueryData {
- googleSheetsSyncs: GoogleSheetsSyncItem[]
-}
-
-interface GoogleSheetsSyncsQueryVariables {
- filter: {
- journeyId?: string
- integrationId?: string
- }
-}
+import { AddSyncFormDialog } from './AddSyncFormDialog'
+import { DeleteSyncDialog } from './DeleteSyncDialog'
+import {
+ EXPORT_TO_SHEETS,
+ GET_JOURNEY_CREATED_AT,
+ INTEGRATION_GOOGLE_CREATE
+} from './graphql'
+import { MobileSyncCard } from './MobileSyncCard'
+import { SyncTable } from './SyncTable'
+import { SyncFormValues } from './types'
+import { useGooglePicker } from './libs/useGooglePicker'
+import { useGoogleSheetsSync } from './libs/useGoogleSheetsSync'
interface GoogleSheetsSyncDialogProps {
open: boolean
@@ -156,39 +48,90 @@ export function GoogleSheetsSyncDialog({
const { t } = useTranslation('apps-journeys-admin')
const { enqueueSnackbar } = useSnackbar()
const router = useRouter()
+ const user = useUser()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const { data: journeyData } = useQuery(GET_JOURNEY_CREATED_AT, {
variables: { id: journeyId }
})
- const { data: integrationsData } = useIntegrationQuery({
- teamId: journeyData?.journey?.team?.id as string
- })
+ const { data: integrationsData, refetch: refetchIntegrations } =
+ useIntegrationQuery({
+ teamId: journeyData?.journey?.team?.id as string
+ })
const [googleDialogOpen, setGoogleDialogOpen] = useState(false)
- const [pickerActive, setPickerActive] = useState(false)
const [exportToSheets, { loading: sheetsLoading }] =
useMutation(EXPORT_TO_SHEETS)
- const [getPickerToken] = useLazyQuery(GET_GOOGLE_PICKER_TOKEN)
- const [
+
+ const teamId = journeyData?.journey?.team?.id as string | undefined
+
+ const { pickerActive, handleOpenDrivePicker } = useGooglePicker({ teamId })
+
+ const {
loadSyncs,
- { data: syncsData, loading: syncsLoading, called: syncsCalled }
- ] = useLazyQuery(
- GET_GOOGLE_SHEETS_SYNCS
- )
- const [deleteSync] = useMutation(DELETE_GOOGLE_SHEETS_SYNC)
- const [backfillSync] = useMutation(BACKFILL_GOOGLE_SHEETS_SYNC)
+ syncsLoading,
+ syncsCalled,
+ activeSyncs,
+ historySyncs,
+ syncsResolved,
+ hasNoSyncs,
+ deletingSyncId,
+ syncIdPendingDelete,
+ setSyncIdPendingDelete,
+ backfillingSyncId,
+ handleDeleteSync,
+ handleRequestDeleteSync,
+ handleBackfillSync
+ } = useGoogleSheetsSync({ journeyId, open })
+
+ const [integrationGoogleCreate] = useMutation(INTEGRATION_GOOGLE_CREATE)
+
+ const hideMainDialog = !syncsResolved || hasNoSyncs
+ const isGoogleActionDisabled = integrationsData == null
- const [deletingSyncId, setDeletingSyncId] = useState(null)
- const [syncIdPendingDelete, setSyncIdPendingDelete] = useState(
- null
- )
- const [backfillingSyncId, setBackfillingSyncId] = useState(
- null
- )
+ async function handleIntegrationCreate(authCode: string): Promise {
+ if (teamId == null || typeof window === 'undefined') return
+
+ const redirectUri = `${window.location.origin}/api/integrations/google/callback`
+
+ const newQuery = { ...router.query }
+ delete newQuery.code
+ delete newQuery.openSyncDialog
+ void router.replace(
+ { pathname: router.pathname, query: newQuery },
+ undefined,
+ { shallow: true }
+ )
+
+ try {
+ const { data } = await integrationGoogleCreate({
+ variables: {
+ input: { teamId, code: authCode, redirectUri }
+ }
+ })
+
+ if (data?.integrationGoogleCreate?.id != null) {
+ await refetchIntegrations()
+ enqueueSnackbar(t('Google integration created successfully'), {
+ variant: 'success'
+ })
+ setGoogleDialogOpen(true)
+ } else {
+ enqueueSnackbar(
+ t('Google settings failed. Reload the page or try again.'),
+ { variant: 'error' }
+ )
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ enqueueSnackbar(error.message, { variant: 'error' })
+ }
+ }
+ }
+ // Load syncs and handle OAuth return
useEffect(() => {
if (!open) return
void loadSyncs({
@@ -196,55 +139,50 @@ export function GoogleSheetsSyncDialog({
fetchPolicy: 'network-only'
})
- // Check if returning from Google integration creation
- const integrationCreated = router.query.integrationCreated === 'true'
const openSyncDialog = router.query.openSyncDialog === 'true'
+ const authCode = router.query.code as string | undefined
+ if (authCode != null && openSyncDialog) {
+ if (user.clientInitialized) {
+ void handleIntegrationCreate(authCode)
+ }
+ return
+ }
+
+ const integrationCreated = router.query.integrationCreated === 'true'
if (integrationCreated && openSyncDialog) {
- // Remove query parameters from URL
const newQuery = { ...router.query }
delete newQuery.integrationCreated
delete newQuery.openSyncDialog
void router.replace(
- {
- pathname: router.pathname,
- query: newQuery
- },
+ { pathname: router.pathname, query: newQuery },
undefined,
{ shallow: true }
)
-
- // Open the "Add Google Sheets Sync" dialog
setGoogleDialogOpen(true)
enqueueSnackbar(t('Google integration created successfully'), {
variant: 'success'
})
}
- }, [open, journeyId, loadSyncs, router, enqueueSnackbar, t])
-
- useEffect(() => {
- if (open) return
- if (deletingSyncId != null) return
- setSyncIdPendingDelete(null)
- }, [open, deletingSyncId])
-
- const googleSheetsSyncs = syncsData?.googleSheetsSyncs ?? []
- const activeSyncs = googleSheetsSyncs.filter((sync) => sync.deletedAt == null)
- const historySyncs = googleSheetsSyncs.filter(
- (sync) => sync.deletedAt != null
- )
+ }, [
+ open,
+ journeyId,
+ loadSyncs,
+ router,
+ enqueueSnackbar,
+ t,
+ user.clientInitialized
+ ])
- // Auto-open "Add Google Sheets Sync" dialog if there are no syncs
+ // Auto-open add dialog when no syncs exist
useEffect(() => {
if (!open) return
- // Wait until the query has actually been executed at least once
if (!syncsCalled) return
if (syncsLoading) return
- // Skip if we're already handling integration creation return flow
const integrationCreated = router.query.integrationCreated === 'true'
- if (integrationCreated) return
+ const hasAuthCode = router.query.code != null
+ if (integrationCreated || hasAuthCode) return
- // If there are no active or history syncs, open the add dialog directly
if (activeSyncs.length === 0 && historySyncs.length === 0) {
setGoogleDialogOpen(true)
}
@@ -257,180 +195,9 @@ export function GoogleSheetsSyncDialog({
router.query.integrationCreated
])
- function getStartedByLabel(sync: GoogleSheetsSyncItem): string {
- if (sync.integration?.__typename === 'IntegrationGoogle') {
- return sync.integration.accountEmail ?? sync.email ?? 'N/A'
- }
-
- if (sync.email != null && sync.email !== '') return sync.email
-
- return 'N/A'
- }
-
- function getSpreadsheetUrl(sync: GoogleSheetsSyncItem): string | null {
- if (sync.spreadsheetId == null || sync.spreadsheetId === '') return null
- return `https://docs.google.com/spreadsheets/d/${sync.spreadsheetId}`
- }
-
- function handleOpenSyncRow(sync: GoogleSheetsSyncItem): void {
- const spreadsheetUrl = getSpreadsheetUrl(sync)
- if (spreadsheetUrl == null) {
- enqueueSnackbar(t('Something went wrong, please try again!'), {
- variant: 'error'
- })
- return
- }
-
- if (typeof window === 'undefined') return
-
- window.open(spreadsheetUrl, '_blank', 'noopener,noreferrer')
- }
-
- function handleSyncRowKeyDown(
- event: KeyboardEvent,
- sync: GoogleSheetsSyncItem
- ): void {
- if (event.key === 'Enter') {
- handleOpenSyncRow(sync)
- return
- }
-
- if (event.key === ' ') {
- event.preventDefault()
- handleOpenSyncRow(sync)
- }
- }
-
- async function handleOpenDrivePicker(
- mode: 'folder' | 'sheet',
- integrationId: string | undefined,
- setFieldValue: (field: string, value: unknown) => void
- ): Promise {
- try {
- const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY
- if (apiKey == null || apiKey === '') {
- enqueueSnackbar(t('Missing Google API key'), { variant: 'error' })
- return
- }
- if (integrationId == null || integrationId === '') {
- enqueueSnackbar(t('Select an integration account first'), {
- variant: 'error'
- })
- return
- }
-
- let oauthToken: string | undefined | null
- const teamId = journeyData?.journey?.team?.id
- if (teamId != null) {
- const { data: tokenData } = await getPickerToken({
- variables: { teamId, integrationId }
- })
- oauthToken = tokenData?.integrationGooglePickerToken
- }
- if (oauthToken == null || oauthToken === '') {
- enqueueSnackbar(t('Unable to authorize Google Picker'), {
- variant: 'error'
- })
- return
- }
-
- await ensurePickerLoaded()
-
- // Mark picker as active to lower dialog z-index
- setPickerActive(true)
-
- const googleAny: any = (window as any).google
- const view =
- mode === 'sheet'
- ? new googleAny.picker.DocsView(googleAny.picker.ViewId.SPREADSHEETS)
- : new googleAny.picker.DocsView(
- googleAny.picker.ViewId.FOLDERS
- ).setSelectFolderEnabled(true)
-
- const picker = new googleAny.picker.PickerBuilder()
- .setOAuthToken(oauthToken)
- .setDeveloperKey(apiKey)
- .addView(view)
- .setCallback((pickerData: any) => {
- if (pickerData?.action === googleAny.picker.Action.PICKED) {
- const doc = pickerData.docs?.[0]
- if (doc != null) {
- const docName = doc?.name ?? doc?.title ?? doc?.id ?? null
- if (mode === 'sheet') {
- setFieldValue('existingSpreadsheetId', doc.id)
- setFieldValue('existingSpreadsheetName', docName ?? undefined)
- } else {
- setFieldValue('folderId', doc.id)
- setFieldValue('folderName', docName ?? undefined)
- }
- }
- }
-
- if (
- pickerData?.action === googleAny.picker.Action.PICKED ||
- pickerData?.action === googleAny.picker.Action.CANCEL
- ) {
- setPickerActive(false)
- }
- })
- .build()
-
- picker.setVisible(true)
- elevatePickerZIndexWithRetries()
- } catch (err) {
- enqueueSnackbar(t('Failed to open Google Picker'), { variant: 'error' })
- setPickerActive(false)
- }
- }
-
- async function ensurePickerLoaded(): Promise {
- const win = window as any
- if (win.google?.picker != null) return
- await new Promise((resolve, reject) => {
- const script = document.createElement('script')
- script.src = 'https://apis.google.com/js/api.js'
- script.async = true
- script.onload = () => {
- const gapi = (window as any).gapi
- if (gapi?.load != null) {
- gapi.load('picker', { callback: resolve })
- } else {
- resolve()
- }
- }
- script.onerror = () => reject(new Error('Failed to load Google API'))
- document.body.appendChild(script)
- })
- }
-
- function elevatePickerZIndex(): void {
- const pickerElements = document.querySelectorAll(
- '.picker-dialog, .picker-dialog-bg, .picker.modal-dialog, [class*="picker"]'
- )
-
- if (pickerElements.length === 0) return
-
- // Ensure the Google Picker is always above any MUI dialog or overlay.
- // Use a very high static value to stay above custom MUI z-index configurations.
- const pickerZIndex = '99999'
-
- pickerElements.forEach((element) => {
- element.style.zIndex = pickerZIndex
- })
- }
-
- function elevatePickerZIndexWithRetries(attempts = 100, delayMs = 100): void {
- elevatePickerZIndex()
- if (attempts <= 1) return
- setTimeout(
- () => elevatePickerZIndexWithRetries(attempts - 1, delayMs),
- delayMs
- )
- }
-
async function handleExportToSheets(
- values: FormikValues,
- actions: FormikHelpers
+ values: SyncFormValues,
+ actions: FormikHelpers
): Promise {
const destination =
values.googleMode === 'create'
@@ -448,7 +215,6 @@ export function GoogleSheetsSyncDialog({
}
try {
- // Get user's timezone to store with sync for consistent date formatting
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const { data } = await exportToSheets({
@@ -467,6 +233,7 @@ export function GoogleSheetsSyncDialog({
? `https://docs.google.com/spreadsheets/d/${syncResult.spreadsheetId}`
: null)
setGoogleDialogOpen(false)
+ if (hideMainDialog) onClose()
actions.resetForm()
if (typeof window !== 'undefined' && spreadsheetUrl != null) {
window.open(spreadsheetUrl, '_blank', 'noopener,noreferrer')
@@ -482,184 +249,15 @@ export function GoogleSheetsSyncDialog({
}
}
- async function handleDeleteSync(syncId: string): Promise {
- setDeletingSyncId(syncId)
- try {
- await deleteSync({
- variables: { id: syncId },
- refetchQueries: [
- {
- query: GET_GOOGLE_SHEETS_SYNCS,
- variables: { filter: { journeyId } }
- }
- ],
- awaitRefetchQueries: true
- })
- enqueueSnackbar(t('Sync removed'), { variant: 'success' })
- } catch (error) {
- enqueueSnackbar((error as Error).message, { variant: 'error' })
- } finally {
- setDeletingSyncId(null)
- setSyncIdPendingDelete(null)
- }
- }
-
- function handleRequestDeleteSync(syncId: string): void {
- setSyncIdPendingDelete(syncId)
- }
-
- async function handleBackfillSync(syncId: string): Promise {
- setBackfillingSyncId(syncId)
- try {
- await backfillSync({
- variables: { id: syncId }
- })
- enqueueSnackbar(
- t('Backfill started. Your sheet will be updated shortly.'),
- { variant: 'success' }
- )
- } catch (error) {
- enqueueSnackbar((error as Error).message, { variant: 'error' })
- } finally {
- setBackfillingSyncId(null)
- }
- }
-
- const isGoogleActionDisabled = integrationsData == null
-
- const validationSchema = object().shape({
- integrationId: string().required(t('Integration account is required')),
- sheetName: string().required(t('Sheet tab name is required')),
- spreadsheetTitle: string().when(
- 'googleMode',
- (googleMode: unknown, schema) =>
- googleMode === 'create'
- ? schema.required(t('Sheet name is required'))
- : schema.notRequired()
- )
- })
-
- const RenderMobileSyncCard = ({
- sync,
- isHistory = false
- }: {
- sync: GoogleSheetsSyncItem
- isHistory?: boolean
- }): ReactElement => {
- const spreadsheetUrl = getSpreadsheetUrl(sync)
- const createdAtDate = new Date(sync.createdAt)
- const formattedDate = !Number.isNaN(createdAtDate.getTime())
- ? format(createdAtDate, 'yyyy-MM-dd')
- : 'N/A'
- const startedBy = getStartedByLabel(sync)
- const isDeleting = deletingSyncId === sync.id
-
- return (
-
-
- {
- if (spreadsheetUrl == null) e.preventDefault()
- }}
- >
- {sync.sheetName ?? sync.spreadsheetId ?? t('Not found')}
-
-
-
-
-
-
-
- {t('Sync Start:')}{' '}
-
- {formattedDate}
-
-
-
- {t('By:')}{' '}
-
- {startedBy}
-
-
-
- {!isHistory && (
-
-
-
-
- handleBackfillSync(sync.id)}
- disabled={backfillingSyncId === sync.id || isDeleting}
- size="small"
- aria-label={t('Backfill sync')}
- >
- {backfillingSyncId === sync.id ? (
-
- ) : (
-
- )}
-
-
- handleRequestDeleteSync(sync.id)}
- disabled={isDeleting || backfillingSyncId === sync.id}
- size="small"
- >
- {isDeleting ? (
-
- ) : (
-
- )}
-
-
-
- )}
-
- )
+ function handleCloseAddDialog(): void {
+ setGoogleDialogOpen(false)
+ if (hideMainDialog) onClose()
}
return (
<>
-
- {({
- values,
- handleChange,
- handleBlur,
- handleSubmit,
- errors,
- touched,
- resetForm,
- setFieldValue
- }) => (
-
- )}
-
-
-
+ />
>
)
}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/MobileSyncCard/MobileSyncCard.tsx b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/MobileSyncCard/MobileSyncCard.tsx
new file mode 100644
index 00000000000..b49ca263047
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/MobileSyncCard/MobileSyncCard.tsx
@@ -0,0 +1,145 @@
+import LaunchIcon from '@mui/icons-material/Launch'
+import RefreshIcon from '@mui/icons-material/Refresh'
+import Box from '@mui/material/Box'
+import Chip from '@mui/material/Chip'
+import CircularProgress from '@mui/material/CircularProgress'
+import IconButton from '@mui/material/IconButton'
+import Link from '@mui/material/Link'
+import Tooltip from '@mui/material/Tooltip'
+import Typography from '@mui/material/Typography'
+import { format } from 'date-fns'
+import { useTranslation } from 'next-i18next'
+import { ReactElement } from 'react'
+
+import Trash2Icon from '@core/shared/ui/icons/Trash2'
+
+import { GoogleSheetsSyncItem } from '../types'
+import { getSpreadsheetUrl, getStartedByLabel } from '../libs/googleSheetsSyncUtils'
+
+interface MobileSyncCardProps {
+ sync: GoogleSheetsSyncItem
+ isHistory?: boolean
+ deletingSyncId?: string | null
+ backfillingSyncId?: string | null
+ onRequestDelete?: (syncId: string) => void
+ onBackfill?: (syncId: string) => void
+}
+
+export function MobileSyncCard({
+ sync,
+ isHistory = false,
+ deletingSyncId,
+ backfillingSyncId,
+ onRequestDelete,
+ onBackfill
+}: MobileSyncCardProps): ReactElement {
+ const { t } = useTranslation('apps-journeys-admin')
+ const spreadsheetUrl = getSpreadsheetUrl(sync)
+ const createdAtDate = new Date(sync.createdAt)
+ const formattedDate = !Number.isNaN(createdAtDate.getTime())
+ ? format(createdAtDate, 'yyyy-MM-dd')
+ : 'N/A'
+ const startedBy = getStartedByLabel(sync)
+ const isDeleting = deletingSyncId === sync.id
+
+ return (
+
+
+ {
+ if (spreadsheetUrl == null) e.preventDefault()
+ }}
+ >
+ {sync.sheetName ?? sync.spreadsheetId ?? t('Not found')}
+
+
+
+
+
+
+
+ {t('Sync Start:')}{' '}
+
+ {formattedDate}
+
+
+
+ {t('By:')}{' '}
+
+ {startedBy}
+
+
+
+ {!isHistory && (
+
+
+
+
+ onBackfill?.(sync.id)}
+ disabled={backfillingSyncId === sync.id || isDeleting}
+ size="small"
+ aria-label={t('Backfill sync')}
+ >
+ {backfillingSyncId === sync.id ? (
+
+ ) : (
+
+ )}
+
+
+ onRequestDelete?.(sync.id)}
+ disabled={isDeleting || backfillingSyncId === sync.id}
+ size="small"
+ >
+ {isDeleting ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/MobileSyncCard/index.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/MobileSyncCard/index.ts
new file mode 100644
index 00000000000..86066a9b001
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/MobileSyncCard/index.ts
@@ -0,0 +1 @@
+export { MobileSyncCard } from './MobileSyncCard'
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/SyncTable/SyncTable.tsx b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/SyncTable/SyncTable.tsx
new file mode 100644
index 00000000000..fd4da0b436b
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/SyncTable/SyncTable.tsx
@@ -0,0 +1,244 @@
+import LaunchIcon from '@mui/icons-material/Launch'
+import NorthEastIcon from '@mui/icons-material/NorthEast'
+import RefreshIcon from '@mui/icons-material/Refresh'
+import Box from '@mui/material/Box'
+import Chip from '@mui/material/Chip'
+import CircularProgress from '@mui/material/CircularProgress'
+import IconButton from '@mui/material/IconButton'
+import Table from '@mui/material/Table'
+import TableBody from '@mui/material/TableBody'
+import TableCell from '@mui/material/TableCell'
+import TableContainer from '@mui/material/TableContainer'
+import TableHead from '@mui/material/TableHead'
+import TableRow from '@mui/material/TableRow'
+import Tooltip from '@mui/material/Tooltip'
+import Typography from '@mui/material/Typography'
+import { format } from 'date-fns'
+import { useTranslation } from 'next-i18next'
+import { useSnackbar } from 'notistack'
+import { KeyboardEvent, ReactElement } from 'react'
+
+import Trash2Icon from '@core/shared/ui/icons/Trash2'
+
+import { GoogleSheetsSyncItem } from '../types'
+import { getSpreadsheetUrl, getStartedByLabel } from '../libs/googleSheetsSyncUtils'
+
+interface SyncTableProps {
+ syncs: GoogleSheetsSyncItem[]
+ variant: 'active' | 'history'
+ deletingSyncId?: string | null
+ backfillingSyncId?: string | null
+ onRequestDelete?: (syncId: string) => void
+ onBackfill?: (syncId: string) => void
+}
+
+export function SyncTable({
+ syncs,
+ variant,
+ deletingSyncId,
+ backfillingSyncId,
+ onRequestDelete,
+ onBackfill
+}: SyncTableProps): ReactElement {
+ const { t } = useTranslation('apps-journeys-admin')
+ const { enqueueSnackbar } = useSnackbar()
+ const isActive = variant === 'active'
+
+ function handleOpenSyncRow(sync: GoogleSheetsSyncItem): void {
+ const spreadsheetUrl = getSpreadsheetUrl(sync)
+ if (spreadsheetUrl == null) {
+ enqueueSnackbar(t('Something went wrong, please try again!'), {
+ variant: 'error'
+ })
+ return
+ }
+ if (typeof window === 'undefined') return
+ window.open(spreadsheetUrl, '_blank', 'noopener,noreferrer')
+ }
+
+ function handleSyncRowKeyDown(
+ event: KeyboardEvent,
+ sync: GoogleSheetsSyncItem
+ ): void {
+ if (event.key === 'Enter') {
+ handleOpenSyncRow(sync)
+ return
+ }
+ if (event.key === ' ') {
+ event.preventDefault()
+ handleOpenSyncRow(sync)
+ }
+ }
+
+ return (
+
+
+
+
+ {t('Sheet Name')}
+
+ {isActive ? t('Sync Start') : t('Removed At')}
+
+ {t('Started By')}
+ {t('Status')}
+ {isActive && (
+
+ {t('Actions')}
+
+ )}
+
+
+
+ {syncs.map((sync) => {
+ const dateValue = isActive ? sync.createdAt : sync.deletedAt
+ const parsedDate = dateValue != null ? new Date(dateValue) : null
+ const formattedDate =
+ parsedDate != null && !Number.isNaN(parsedDate.getTime())
+ ? format(parsedDate, 'yyyy-MM-dd')
+ : 'N/A'
+ const startedBy = getStartedByLabel(sync)
+ const isDeleting = deletingSyncId === sync.id
+ const LinkIcon = isActive ? NorthEastIcon : LaunchIcon
+
+ return (
+ handleOpenSyncRow(sync)}
+ onKeyDown={(event) => handleSyncRowKeyDown(event, sync)}
+ sx={{
+ cursor: 'pointer',
+ '&:focus-visible': {
+ outline: (theme) =>
+ `2px solid ${theme.palette.primary.main}`,
+ outlineOffset: 2
+ }
+ }}
+ >
+
+
+
+
+ {sync.sheetName ?? 'N/A'}
+
+
+
+
+
+ {formattedDate}
+ {startedBy}
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+
+ {isActive && (
+
+
+
+ {
+ event.stopPropagation()
+ onBackfill?.(sync.id)
+ }}
+ >
+ {backfillingSyncId === sync.id ? (
+
+ ) : (
+
+ )}
+
+
+ {
+ event.stopPropagation()
+ onRequestDelete?.(sync.id)
+ }}
+ >
+ {isDeleting ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/SyncTable/index.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/SyncTable/index.ts
new file mode 100644
index 00000000000..8cf7ed9d1ae
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/SyncTable/index.ts
@@ -0,0 +1 @@
+export { SyncTable } from './SyncTable'
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/graphql.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/graphql.ts
new file mode 100644
index 00000000000..93ee650b70c
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/graphql.ts
@@ -0,0 +1,84 @@
+import { gql } from '@apollo/client'
+
+export const GET_JOURNEY_CREATED_AT = gql`
+ query GoogleSheetsSyncDialogJourney($id: ID!) {
+ journey: adminJourney(id: $id, idType: databaseId) {
+ id
+ createdAt
+ title
+ team {
+ id
+ }
+ }
+ }
+`
+
+export const GET_GOOGLE_PICKER_TOKEN = gql`
+ query IntegrationGooglePickerToken($integrationId: ID!) {
+ integrationGooglePickerToken(integrationId: $integrationId)
+ }
+`
+
+export const GET_GOOGLE_SHEETS_SYNCS = gql`
+ query GoogleSheetsSyncs($filter: GoogleSheetsSyncsFilter!) {
+ googleSheetsSyncs(filter: $filter) {
+ id
+ spreadsheetId
+ sheetName
+ email
+ deletedAt
+ createdAt
+ integration {
+ __typename
+ id
+ ... on IntegrationGoogle {
+ accountEmail
+ }
+ }
+ }
+ }
+`
+
+export const EXPORT_TO_SHEETS = gql`
+ mutation JourneyVisitorExportToGoogleSheet(
+ $journeyId: ID!
+ $destination: JourneyVisitorGoogleSheetDestinationInput!
+ $integrationId: ID!
+ $timezone: String
+ ) {
+ journeyVisitorExportToGoogleSheet(
+ journeyId: $journeyId
+ destination: $destination
+ integrationId: $integrationId
+ timezone: $timezone
+ ) {
+ spreadsheetId
+ spreadsheetUrl
+ sheetName
+ }
+ }
+`
+
+export const DELETE_GOOGLE_SHEETS_SYNC = gql`
+ mutation GoogleSheetsSyncDialogDelete($id: ID!) {
+ googleSheetsSyncDelete(id: $id) {
+ id
+ }
+ }
+`
+
+export const BACKFILL_GOOGLE_SHEETS_SYNC = gql`
+ mutation GoogleSheetsSyncDialogBackfill($id: ID!) {
+ googleSheetsSyncBackfill(id: $id) {
+ id
+ }
+ }
+`
+
+export const INTEGRATION_GOOGLE_CREATE = gql`
+ mutation IntegrationGoogleCreate($input: IntegrationGoogleCreateInput!) {
+ integrationGoogleCreate(input: $input) {
+ id
+ }
+ }
+`
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/googleSheetsSyncUtils.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/googleSheetsSyncUtils.ts
new file mode 100644
index 00000000000..d659a696ad2
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/googleSheetsSyncUtils.ts
@@ -0,0 +1,15 @@
+import { GoogleSheetsSyncItem } from '../types'
+
+export function getStartedByLabel(sync: GoogleSheetsSyncItem): string {
+ if (sync.integration?.__typename === 'IntegrationGoogle') {
+ return sync.integration.accountEmail ?? sync.email ?? 'N/A'
+ }
+ if (sync.email != null && sync.email !== '') return sync.email
+ return 'N/A'
+}
+
+export function getSpreadsheetUrl(sync: GoogleSheetsSyncItem): string | null {
+ if (sync.spreadsheetId == null || sync.spreadsheetId === '') return null
+ return `https://docs.google.com/spreadsheets/d/${sync.spreadsheetId}`
+}
+
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGooglePicker/index.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGooglePicker/index.ts
new file mode 100644
index 00000000000..236b0a84106
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGooglePicker/index.ts
@@ -0,0 +1,2 @@
+export { useGooglePicker } from './useGooglePicker'
+export type { UseGooglePickerParams, UseGooglePickerReturn } from './useGooglePicker'
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGooglePicker/useGooglePicker.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGooglePicker/useGooglePicker.ts
new file mode 100644
index 00000000000..a39de7a0d0b
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGooglePicker/useGooglePicker.ts
@@ -0,0 +1,156 @@
+import { useLazyQuery } from '@apollo/client'
+import { useTranslation } from 'next-i18next'
+import { useSnackbar } from 'notistack'
+import { useState } from 'react'
+
+import { GET_GOOGLE_PICKER_TOKEN } from '../../graphql'
+
+const PICKER_Z_INDEX = '99999'
+const PICKER_RETRY_ATTEMPTS = 100
+const PICKER_RETRY_DELAY_MS = 100
+
+export interface UseGooglePickerParams {
+ teamId: string | undefined
+}
+
+export interface UseGooglePickerReturn {
+ pickerActive: boolean
+ handleOpenDrivePicker: (
+ mode: 'folder' | 'sheet',
+ integrationId: string | undefined,
+ setFieldValue: (field: string, value: unknown) => void
+ ) => Promise
+}
+
+export function useGooglePicker({
+ teamId
+}: UseGooglePickerParams): UseGooglePickerReturn {
+ const { t } = useTranslation('apps-journeys-admin')
+ const { enqueueSnackbar } = useSnackbar()
+ const [getPickerToken] = useLazyQuery(GET_GOOGLE_PICKER_TOKEN)
+ const [pickerActive, setPickerActive] = useState(false)
+
+ async function ensurePickerLoaded(): Promise {
+ const win = window as any
+ if (win.google?.picker != null) return
+ await new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.src = 'https://apis.google.com/js/api.js'
+ script.async = true
+ script.onload = () => {
+ const gapi = (window as any).gapi
+ if (gapi?.load != null) {
+ gapi.load('picker', { callback: resolve })
+ } else {
+ resolve()
+ }
+ }
+ script.onerror = () => reject(new Error('Failed to load Google API'))
+ document.body.appendChild(script)
+ })
+ }
+
+ function elevatePickerZIndex(): void {
+ const pickerElements = document.querySelectorAll(
+ '.picker-dialog, .picker-dialog-bg, .picker.modal-dialog, [class*="picker"]'
+ )
+ if (pickerElements.length === 0) return
+ pickerElements.forEach((element) => {
+ element.style.zIndex = PICKER_Z_INDEX
+ })
+ }
+
+ function elevatePickerZIndexWithRetries(
+ attempts = PICKER_RETRY_ATTEMPTS,
+ delayMs = PICKER_RETRY_DELAY_MS
+ ): void {
+ elevatePickerZIndex()
+ if (attempts <= 1) return
+ setTimeout(
+ () => elevatePickerZIndexWithRetries(attempts - 1, delayMs),
+ delayMs
+ )
+ }
+
+ async function handleOpenDrivePicker(
+ mode: 'folder' | 'sheet',
+ integrationId: string | undefined,
+ setFieldValue: (field: string, value: unknown) => void
+ ): Promise {
+ try {
+ const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY
+ if (apiKey == null || apiKey === '') {
+ enqueueSnackbar(t('Missing Google API key'), { variant: 'error' })
+ return
+ }
+ if (integrationId == null || integrationId === '') {
+ enqueueSnackbar(t('Select an integration account first'), {
+ variant: 'error'
+ })
+ return
+ }
+
+ let oauthToken: string | undefined | null
+ if (teamId != null) {
+ const { data: tokenData } = await getPickerToken({
+ variables: { teamId, integrationId }
+ })
+ oauthToken = tokenData?.integrationGooglePickerToken
+ }
+ if (oauthToken == null || oauthToken === '') {
+ enqueueSnackbar(t('Unable to authorize Google Picker'), {
+ variant: 'error'
+ })
+ return
+ }
+
+ await ensurePickerLoaded()
+
+ setPickerActive(true)
+
+ const googleAny: any = (window as any).google
+ const view =
+ mode === 'sheet'
+ ? new googleAny.picker.DocsView(googleAny.picker.ViewId.SPREADSHEETS)
+ : new googleAny.picker.DocsView(
+ googleAny.picker.ViewId.FOLDERS
+ ).setSelectFolderEnabled(true)
+
+ const picker = new googleAny.picker.PickerBuilder()
+ .setOAuthToken(oauthToken)
+ .setDeveloperKey(apiKey)
+ .addView(view)
+ .setCallback((pickerData: any) => {
+ if (pickerData?.action === googleAny.picker.Action.PICKED) {
+ const doc = pickerData.docs?.[0]
+ if (doc != null) {
+ const docName = doc?.name ?? doc?.title ?? doc?.id ?? null
+ if (mode === 'sheet') {
+ setFieldValue('existingSpreadsheetId', doc.id)
+ setFieldValue('existingSpreadsheetName', docName ?? undefined)
+ } else {
+ setFieldValue('folderId', doc.id)
+ setFieldValue('folderName', docName ?? undefined)
+ }
+ }
+ }
+
+ if (
+ pickerData?.action === googleAny.picker.Action.PICKED ||
+ pickerData?.action === googleAny.picker.Action.CANCEL
+ ) {
+ setPickerActive(false)
+ }
+ })
+ .build()
+
+ picker.setVisible(true)
+ elevatePickerZIndexWithRetries()
+ } catch {
+ enqueueSnackbar(t('Failed to open Google Picker'), { variant: 'error' })
+ setPickerActive(false)
+ }
+ }
+
+ return { pickerActive, handleOpenDrivePicker }
+}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGoogleSheetsSync/index.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGoogleSheetsSync/index.ts
new file mode 100644
index 00000000000..f6f974a0df7
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGoogleSheetsSync/index.ts
@@ -0,0 +1,5 @@
+export { useGoogleSheetsSync } from './useGoogleSheetsSync'
+export type {
+ UseGoogleSheetsSyncParams,
+ UseGoogleSheetsSyncReturn
+} from './useGoogleSheetsSync'
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGoogleSheetsSync/useGoogleSheetsSync.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGoogleSheetsSync/useGoogleSheetsSync.ts
new file mode 100644
index 00000000000..8a440c043ba
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/libs/useGoogleSheetsSync/useGoogleSheetsSync.ts
@@ -0,0 +1,142 @@
+import { LazyQueryExecFunction, useLazyQuery, useMutation } from '@apollo/client'
+import { useTranslation } from 'next-i18next'
+import { useSnackbar } from 'notistack'
+import { useEffect, useState } from 'react'
+
+import {
+ BACKFILL_GOOGLE_SHEETS_SYNC,
+ DELETE_GOOGLE_SHEETS_SYNC,
+ GET_GOOGLE_SHEETS_SYNCS
+} from '../../graphql'
+import {
+ GoogleSheetsSyncItem,
+ GoogleSheetsSyncsQueryData,
+ GoogleSheetsSyncsQueryVariables
+} from '../../types'
+
+export interface UseGoogleSheetsSyncParams {
+ journeyId: string
+ open: boolean
+}
+
+export interface UseGoogleSheetsSyncReturn {
+ loadSyncs: LazyQueryExecFunction<
+ GoogleSheetsSyncsQueryData,
+ GoogleSheetsSyncsQueryVariables
+ >
+ syncsLoading: boolean
+ syncsCalled: boolean
+ activeSyncs: GoogleSheetsSyncItem[]
+ historySyncs: GoogleSheetsSyncItem[]
+ syncsResolved: boolean
+ hasNoSyncs: boolean
+ deletingSyncId: string | null
+ syncIdPendingDelete: string | null
+ setSyncIdPendingDelete: (id: string | null) => void
+ backfillingSyncId: string | null
+ handleDeleteSync: (syncId: string) => Promise
+ handleRequestDeleteSync: (syncId: string) => void
+ handleBackfillSync: (syncId: string) => Promise
+}
+
+export function useGoogleSheetsSync({
+ journeyId,
+ open
+}: UseGoogleSheetsSyncParams): UseGoogleSheetsSyncReturn {
+ const { t } = useTranslation('apps-journeys-admin')
+ const { enqueueSnackbar } = useSnackbar()
+
+ const [
+ loadSyncs,
+ { data: syncsData, loading: syncsLoading, called: syncsCalled }
+ ] = useLazyQuery(
+ GET_GOOGLE_SHEETS_SYNCS
+ )
+ const [deleteSync] = useMutation(DELETE_GOOGLE_SHEETS_SYNC)
+ const [backfillSync] = useMutation(BACKFILL_GOOGLE_SHEETS_SYNC)
+
+ const [deletingSyncId, setDeletingSyncId] = useState(null)
+ const [syncIdPendingDelete, setSyncIdPendingDelete] = useState(
+ null
+ )
+ const [backfillingSyncId, setBackfillingSyncId] = useState(
+ null
+ )
+
+ useEffect(() => {
+ if (open) return
+ if (deletingSyncId != null) return
+ setSyncIdPendingDelete(null)
+ }, [open, deletingSyncId])
+
+ const googleSheetsSyncs = syncsData?.googleSheetsSyncs ?? []
+ const activeSyncs = googleSheetsSyncs.filter(
+ (sync) => sync.deletedAt == null
+ )
+ const historySyncs = googleSheetsSyncs.filter(
+ (sync) => sync.deletedAt != null
+ )
+ const syncsResolved = syncsCalled && !syncsLoading
+ const hasNoSyncs =
+ syncsResolved && activeSyncs.length === 0 && historySyncs.length === 0
+
+ async function handleDeleteSync(syncId: string): Promise {
+ setDeletingSyncId(syncId)
+ try {
+ await deleteSync({
+ variables: { id: syncId },
+ refetchQueries: [
+ {
+ query: GET_GOOGLE_SHEETS_SYNCS,
+ variables: { filter: { journeyId } }
+ }
+ ],
+ awaitRefetchQueries: true
+ })
+ enqueueSnackbar(t('Sync removed'), { variant: 'success' })
+ } catch (error) {
+ enqueueSnackbar((error as Error).message, { variant: 'error' })
+ } finally {
+ setDeletingSyncId(null)
+ setSyncIdPendingDelete(null)
+ }
+ }
+
+ function handleRequestDeleteSync(syncId: string): void {
+ setSyncIdPendingDelete(syncId)
+ }
+
+ async function handleBackfillSync(syncId: string): Promise {
+ setBackfillingSyncId(syncId)
+ try {
+ await backfillSync({
+ variables: { id: syncId }
+ })
+ enqueueSnackbar(
+ t('Backfill started. Your sheet will be updated shortly.'),
+ { variant: 'success' }
+ )
+ } catch (error) {
+ enqueueSnackbar((error as Error).message, { variant: 'error' })
+ } finally {
+ setBackfillingSyncId(null)
+ }
+ }
+
+ return {
+ loadSyncs,
+ syncsLoading,
+ syncsCalled,
+ activeSyncs,
+ historySyncs,
+ syncsResolved,
+ hasNoSyncs,
+ deletingSyncId,
+ syncIdPendingDelete,
+ setSyncIdPendingDelete,
+ backfillingSyncId,
+ handleDeleteSync,
+ handleRequestDeleteSync,
+ handleBackfillSync
+ }
+}
diff --git a/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/types.ts b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/types.ts
new file mode 100644
index 00000000000..721d5b2bcef
--- /dev/null
+++ b/apps/journeys-admin/src/components/JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/types.ts
@@ -0,0 +1,35 @@
+export interface GoogleSheetsSyncItem {
+ id: string
+ spreadsheetId: string | null
+ sheetName: string | null
+ email: string | null
+ deletedAt: string | null
+ createdAt: string
+ integration: {
+ __typename: string
+ id: string
+ accountEmail?: string | null
+ } | null
+}
+
+export interface GoogleSheetsSyncsQueryData {
+ googleSheetsSyncs: GoogleSheetsSyncItem[]
+}
+
+export interface GoogleSheetsSyncsQueryVariables {
+ filter: {
+ journeyId?: string
+ integrationId?: string
+ }
+}
+
+export interface SyncFormValues {
+ integrationId: string
+ googleMode: '' | 'create' | 'existing'
+ spreadsheetTitle: string
+ sheetName: string
+ folderId?: string
+ folderName?: string
+ existingSpreadsheetId?: string
+ existingSpreadsheetName?: string
+}
diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.spec.tsx
index b4000cd53cb..48829c06ba3 100644
--- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.spec.tsx
+++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.spec.tsx
@@ -1,4 +1,4 @@
-import { MockedProvider } from '@apollo/client/testing'
+import { MockedProvider, MockedResponse } from '@apollo/client/testing'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NextRouter, useRouter } from 'next/router'
import { SnackbarProvider } from 'notistack'
@@ -12,8 +12,12 @@ import {
} from '../../../../../../__generated__/globalTypes'
import { GET_CUSTOM_DOMAINS } from '../../../../../libs/useCustomDomainsQuery/useCustomDomainsQuery'
import { GET_JOURNEY_FOR_SHARING } from '../../../../../libs/useJourneyForShareLazyQuery/useJourneyForShareLazyQuery'
+import { useJourneyNotifcationUpdateMock } from '../../../../../libs/useJourneyNotificationUpdate/useJourneyNotificationUpdate.mock'
-import { DoneScreen } from './DoneScreen'
+import {
+ DoneScreen,
+ GET_GOOGLE_SHEETS_SYNCS_FOR_DONE_SCREEN
+} from './DoneScreen'
jest.mock('next/router', () => ({
__esModule: true,
@@ -51,6 +55,30 @@ const getCustomDomainsMock = {
}
}
+const googleSheetsSyncsNoActiveMock: MockedResponse = {
+ request: {
+ query: GET_GOOGLE_SHEETS_SYNCS_FOR_DONE_SCREEN,
+ variables: { filter: { journeyId: 'journeyId' } }
+ },
+ result: {
+ data: {
+ googleSheetsSyncs: []
+ }
+ }
+}
+
+const googleSheetsSyncsWithActiveMock: MockedResponse = {
+ request: {
+ query: GET_GOOGLE_SHEETS_SYNCS_FOR_DONE_SCREEN,
+ variables: { filter: { journeyId: 'journeyId' } }
+ },
+ result: {
+ data: {
+ googleSheetsSyncs: [{ id: 'syncId', deletedAt: null }]
+ }
+ }
+}
+
const journeyForSharingMock = {
request: {
query: GET_JOURNEY_FOR_SHARING,
@@ -121,7 +149,9 @@ describe('DoneScreen', () => {
it('renders the completion message', () => {
render(
-
+
@@ -133,7 +163,9 @@ describe('DoneScreen', () => {
it('renders first card of journey as preview', async () => {
render(
-
+
@@ -145,7 +177,9 @@ describe('DoneScreen', () => {
it('renders all action buttons', () => {
render(
-
+
@@ -164,7 +198,9 @@ describe('DoneScreen', () => {
}
render(
-
+
@@ -185,8 +221,16 @@ describe('DoneScreen', () => {
id: 'test-journey-id'
}
+ const syncsForTestJourneyMock: MockedResponse = {
+ request: {
+ query: GET_GOOGLE_SHEETS_SYNCS_FOR_DONE_SCREEN,
+ variables: { filter: { journeyId: 'test-journey-id' } }
+ },
+ result: { data: { googleSheetsSyncs: [] } }
+ }
+
render(
-
+
@@ -201,6 +245,64 @@ describe('DoneScreen', () => {
expect(push).toHaveBeenCalledWith('/')
})
+ it('renders notification section heading and label', () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ expect(screen.getByText('Choose where responses go:')).toBeInTheDocument()
+ expect(screen.getByText('Send to my email')).toBeInTheDocument()
+ })
+
+ it('renders notification switch unchecked by default', () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ const checkbox = screen.getByRole('checkbox')
+ expect(checkbox).not.toBeChecked()
+ })
+
+ it('fires notification update mutation when switch is toggled', async () => {
+ const result = jest
+ .fn()
+ .mockReturnValueOnce(useJourneyNotifcationUpdateMock.result)
+ render(
+
+
+
+
+
+
+
+ )
+
+ fireEvent.click(screen.getByRole('checkbox'))
+ await waitFor(() => expect(result).toHaveBeenCalled())
+ })
+
it('opens the share dialog when clicked', async () => {
const journeyWithTeam = {
...journey,
@@ -214,7 +316,13 @@ describe('DoneScreen', () => {
render(
-
+
@@ -257,4 +365,65 @@ describe('DoneScreen', () => {
expect(screen.getByText('Link copied')).toBeInTheDocument()
})
})
+
+ it('renders Sync to Google Sheets row with Sync button when no active syncs', async () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ expect(screen.getByText('Sync to Google Sheets')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByTestId('GoogleSheetsSyncButton')).toHaveTextContent(
+ 'Sync'
+ )
+ })
+ })
+
+ it('renders Edit button when active syncs exist', async () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('GoogleSheetsSyncButton')).toHaveTextContent(
+ 'Edit'
+ )
+ })
+ })
+
+ it('opens GoogleSheetsSyncDialog when Sync button is clicked', async () => {
+ render(
+
+
+
+
+
+
+
+ )
+
+ fireEvent.click(screen.getByTestId('GoogleSheetsSyncButton'))
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
})
diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.tsx
index aa4cdc4fbb7..e429fae1e2b 100644
--- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.tsx
+++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/DoneScreen/DoneScreen.tsx
@@ -1,9 +1,10 @@
+import { gql, useQuery } from '@apollo/client'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
-import { ReactElement } from 'react'
+import { ReactElement, useEffect, useState } from 'react'
import { TreeBlock } from '@core/journeys/ui/block'
import { useJourney } from '@core/journeys/ui/JourneyProvider'
@@ -13,13 +14,54 @@ import { GetJourney_journey_blocks_StepBlock as StepBlock } from '@core/journeys
import ArrowRightContained1Icon from '@core/shared/ui/icons/ArrowRightContained1'
import Play3Icon from '@core/shared/ui/icons/Play3'
+import { NotificationSwitch } from '../../../../AccessDialog/NotificationSwitch'
import { ShareItem } from '../../../../Editor/Toolbar/Items/ShareItem'
+import { GoogleSheetsSyncDialog } from '../../../../JourneyVisitorsList/FilterDrawer/GoogleSheetsSyncDialog/GoogleSheetsSyncDialog'
import { ScreenWrapper } from '../ScreenWrapper'
+export const GET_GOOGLE_SHEETS_SYNCS_FOR_DONE_SCREEN = gql`
+ query GoogleSheetsSyncsForDoneScreen($filter: GoogleSheetsSyncsFilter!) {
+ googleSheetsSyncs(filter: $filter) {
+ id
+ deletedAt
+ }
+ }
+`
+
+interface GoogleSheetsSyncsForDoneScreenData {
+ googleSheetsSyncs: Array<{ id: string; deletedAt: string | null }>
+}
+
+interface GoogleSheetsSyncsForDoneScreenVariables {
+ filter: { journeyId: string }
+}
+
export function DoneScreen(): ReactElement {
const { t } = useTranslation('apps-journeys-admin')
const { journey } = useJourney()
const router = useRouter()
+ const [syncDialogOpen, setSyncDialogOpen] = useState(false)
+
+ // Auto-open sync dialog when returning from OAuth flow
+ useEffect(() => {
+ if (journey?.id == null) return
+ const openSyncDialog = router.query.openSyncDialog === 'true'
+ if (openSyncDialog) {
+ setSyncDialogOpen(true)
+ }
+ }, [journey?.id, router.query.openSyncDialog])
+
+ const { data: syncsData, refetch: refetchSyncs } = useQuery<
+ GoogleSheetsSyncsForDoneScreenData,
+ GoogleSheetsSyncsForDoneScreenVariables
+ >(GET_GOOGLE_SHEETS_SYNCS_FOR_DONE_SCREEN, {
+ variables: { filter: { journeyId: journey?.id ?? '' } },
+ skip: journey?.id == null
+ })
+
+ const hasActiveSyncs =
+ syncsData?.googleSheetsSyncs.some((sync) => sync.deletedAt == null) ?? false
+
const journeyPath = `/api/preview?slug=${journey?.slug}`
const href = journey?.slug != null ? journeyPath : undefined
@@ -31,6 +73,15 @@ export function DoneScreen(): ReactElement {
if (journey?.id != null) void router.push('/')
}
+ function handleSyncDialogOpen(): void {
+ setSyncDialogOpen(true)
+ }
+
+ function handleSyncDialogClose(): void {
+ setSyncDialogOpen(false)
+ void refetchSyncs()
+ }
+
return (
- }
+
- {t('Preview')}
-
- }
+ sx={{
+ borderWidth: 2,
+ borderRadius: 2,
height: 48,
- borderRadius: 2
- }
+ width: { xs: '100%', sm: 216 },
+ borderColor: 'secondary.light'
+ }}
+ >
+ {t('Preview')}
+
+
+
+
+ >
+
+ {t('Choose where responses go:')}
+
+
+
+ {t('Send to my email')}
+
+
+
+
+ {t('Sync to Google Sheets')}
+
+
+
+
+
+ {journey?.id != null && (
+
+ )}
)
}
diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/LanguageScreen/LanguageScreen.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/LanguageScreen/LanguageScreen.tsx
index db068ce2b66..976f92ebb38 100644
--- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/LanguageScreen/LanguageScreen.tsx
+++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/LanguageScreen/LanguageScreen.tsx
@@ -26,7 +26,6 @@ import { useCurrentUserLazyQuery } from '../../../../../libs/useCurrentUserLazyQ
import { useGetChildTemplateJourneyLanguages } from '../../../../../libs/useGetChildTemplateJourneyLanguages'
import { useGetParentTemplateJourneyLanguages } from '../../../../../libs/useGetParentTemplateJourneyLanguages'
import { useTeamCreateMutation } from '../../../../../libs/useTeamCreateMutation'
-import { CustomizationScreen } from '../../../utils/getCustomizeFlowConfig'
import { CustomizeFlowNextButton } from '../../CustomizeFlowNextButton'
import { CardsPreview, EDGE_FADE_PX } from '../LinksScreen/CardsPreview'
import { ScreenWrapper } from '../ScreenWrapper'
@@ -378,7 +377,6 @@ export function LanguageScreen({
>
{t('Select a team')}
-