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 + }) => ( + { + onClose() + resetForm() + }} + dialogTitle={{ + title: t('Sync to Google Sheets'), + closeButton: true + }} + divider={false} + maxWidth="sm" + sx={{ + zIndex: pickerActive ? 1 : undefined + }} + dialogActionChildren={ + + + + + } + > +
+ + + + + + + + {t('Google Account')} + + + {touched.integrationId != null && + errors.integrationId != null && ( + + {errors.integrationId } + + )} + + + + + + + {t('Spreadsheet Setup')} + + + + + {values.googleMode !== '' && ( + + {values.googleMode === 'create' ? ( + + + + {t( + 'Optional: Choose a folder in Google Drive to store your new spreadsheet.' + )} + + + ) : ( + + + + {t( + 'Select a spreadsheet from Google Drive to sync your data.' + )} + + + )} + {values.googleMode === 'create' && ( + + )} + + + )} + + +
+
+ )} +
+ ) +} + 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 ( + { + if (deletingSyncId != null) return + onClose() + }} + dialogTitle={{ + title: t('Delete Google Sheets Sync'), + closeButton: true + }} + divider={false} + maxWidth="sm" + dialogActionChildren={ + + + + + } + > + + + {t( + "Data will no longer update in your Google Sheet if you delete this sync. Existing data will remain, but new updates won't be sent." + )} + + + {t('You will have to start a new sync to re-start syncing.')} + + + + ) +} 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 ( <> ) : ( activeSyncs.map((sync) => ( - + )) )} @@ -737,7 +342,10 @@ export function GoogleSheetsSyncDialog({ sx={{ '&:before': { display: 'none' } }} defaultExpanded > - } sx={{ px: 2 }}> + } + sx={{ px: 2 }} + > {t('Sync History')} @@ -751,7 +359,7 @@ export function GoogleSheetsSyncDialog({ ) : ( historySyncs.map((sync) => ( - ) : ( - - - - - {t('Sheet Name')} - {t('Sync Start')} - {t('Started By')} - {t('Status')} - - {t('Actions')} - - - - - {activeSyncs.map((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 ( - 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} - - - - - - - { - event.stopPropagation() - void handleBackfillSync(sync.id) - }} - > - {backfillingSyncId === sync.id ? ( - - ) : ( - - )} - - - { - event.stopPropagation() - handleRequestDeleteSync(sync.id) - }} - > - {isDeleting ? ( - - ) : ( - - )} - - - - - ) - })} - -
-
+ )} {!isMobile && ( @@ -935,567 +395,34 @@ export function GoogleSheetsSyncDialog({ {t('No removed syncs yet.')} ) : ( - - - - - {t('Sheet Name')} - - {t('Removed At')} - - {t('Started By')} - {t('Status')} - - - - {historySyncs.map((sync) => { - const removedAtDate = sync.deletedAt - ? new Date(sync.deletedAt) - : null - const removedAt = - removedAtDate != null && - !Number.isNaN(removedAtDate.getTime()) - ? format(removedAtDate, 'yyyy-MM-dd') - : 'N/A' - const startedBy = getStartedByLabel(sync) - - 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'} - - - - - - - {removedAt} - - {startedBy} - - - - - ) - })} - -
-
+ )} )}
- - {({ - values, - handleChange, - handleBlur, - handleSubmit, - errors, - touched, - resetForm, - setFieldValue - }) => ( - { - setGoogleDialogOpen(false) - resetForm() - }} - dialogTitle={{ - title: t('Sync to Google Sheets'), - closeButton: true - }} - divider={false} - maxWidth="sm" - sx={{ - zIndex: pickerActive ? 1 : undefined - }} - dialogActionChildren={ - - - - - } - > -
- - - - - - - - {t('Google Account')} - - - {touched.integrationId != null && - errors.integrationId != null && ( - - {errors.integrationId as string} - - )} - - - - - - - {t('Spreadsheet Setup')} - - - - - {values.googleMode !== '' && ( - - {values.googleMode === 'create' ? ( - - - - {t( - 'Optional: Choose a folder in Google Drive to store your new spreadsheet.' - )} - - - ) : ( - - - - {t( - 'Select a spreadsheet from Google Drive to sync your data.' - )} - - - )} - {values.googleMode === 'create' && ( - - )} - - - )} - - -
-
- )} -
- - { - if (deletingSyncId != null) return - setSyncIdPendingDelete(null) + onOpenDrivePicker={handleOpenDrivePicker} + routerAsPath={router.asPath} + /> + + setSyncIdPendingDelete(null)} + onDelete={(syncId) => { + void handleDeleteSync(syncId) }} - dialogTitle={{ - title: t('Delete Google Sheets Sync'), - closeButton: true - }} - divider={false} - maxWidth="sm" - dialogActionChildren={ - - - - - } - > - - - {t( - "Data will no longer update in your Google Sheet if you delete this sync. Existing data will remain, but new updates won't be sent." - )} - - - {t('You will have to start a new sync to re-start syncing.')} - - - + /> ) } 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 ( - - } + 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')} -