From 8e344c421fe9457c3a246630170c68e0c3b3c135 Mon Sep 17 00:00:00 2001 From: Teal Larson Date: Wed, 17 Jul 2024 10:09:16 -0400 Subject: [PATCH] feat: add confirmation modal with context when canceling a job (#13015) Co-authored-by: Joey Marshment-Howell --- .../connection/utils/useInitialStreamSync.ts | 32 +++++++ .../ConnectionSync/CancelJobModalBody.tsx | 51 ++++++++++ .../ConnectionSync/ConnectionSyncContext.tsx | 96 ++++++++++++++++++- .../src/core/services/analytics/types.ts | 2 + .../hooks/services/Experiment/experiments.ts | 1 + airbyte-webapp/src/locales/en.json | 16 ++++ .../ConnectionSettingsPage.test.tsx | 3 + 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 airbyte-webapp/src/area/connection/utils/useInitialStreamSync.ts create mode 100644 airbyte-webapp/src/components/connection/ConnectionSync/CancelJobModalBody.tsx diff --git a/airbyte-webapp/src/area/connection/utils/useInitialStreamSync.ts b/airbyte-webapp/src/area/connection/utils/useInitialStreamSync.ts new file mode 100644 index 00000000000..532472e3592 --- /dev/null +++ b/airbyte-webapp/src/area/connection/utils/useInitialStreamSync.ts @@ -0,0 +1,32 @@ +import isEqual from "lodash/isEqual"; + +import { StreamStatusJobType, StreamStatusRunState } from "core/api/types/AirbyteClient"; + +import { useStreamsStatuses } from "./useStreamsStatuses"; +export const useInitialStreamSync = (connectionId: string) => { + const { streamStatuses, enabledStreams } = useStreamsStatuses(connectionId); + + const streamsSyncingForFirstTime: string[] = []; + + streamStatuses.forEach((stream) => { + const lastSuccessfulClear = stream.relevantHistory?.find( + (status) => status.jobType === StreamStatusJobType.RESET && status.runState === StreamStatusRunState.COMPLETE + ); + const lastSuccessfulSync = stream.relevantHistory?.find( + (status) => status.jobType === StreamStatusJobType.SYNC && status.runState === StreamStatusRunState.COMPLETE + ); + // if the stream has never synced before + if (!lastSuccessfulSync) { + streamsSyncingForFirstTime.push(stream.streamName); + } + // if most clear and was more recent than the most recent sync + if ((lastSuccessfulClear?.transitionedAt ?? 0) > (lastSuccessfulSync?.transitionedAt ?? 0)) { + streamsSyncingForFirstTime.push(stream.streamName); + } + }); + + return { + streamsSyncingForFirstTime, + isConnectionInitialSync: isEqual(streamsSyncingForFirstTime.sort(), enabledStreams.sort()), + }; +}; diff --git a/airbyte-webapp/src/components/connection/ConnectionSync/CancelJobModalBody.tsx b/airbyte-webapp/src/components/connection/ConnectionSync/CancelJobModalBody.tsx new file mode 100644 index 00000000000..28e64675b93 --- /dev/null +++ b/airbyte-webapp/src/components/connection/ConnectionSync/CancelJobModalBody.tsx @@ -0,0 +1,51 @@ +import { FormattedMessage } from "react-intl"; + +import { Text } from "components/ui/Text"; + +import { JobConfigType } from "core/api/types/AirbyteClient"; + +interface CancelJobModalBodyProps { + isConnectionInitialSync: boolean; + streamsSyncingForFirstTime: string[]; + configType: JobConfigType; +} +export const CancelJobModalBody: React.FC = ({ + isConnectionInitialSync, + streamsSyncingForFirstTime, + configType, +}) => { + if (isConnectionInitialSync) { + return ; + } + + if (streamsSyncingForFirstTime.length === 1) { + const streamValues = streamsSyncingForFirstTime.map((stream) => { + return ( + + {stream} + + ); + }); + + return ( + + ); + } + const configTypeId = + configType === JobConfigType.sync + ? "connection.actions.sync" + : configType === JobConfigType.refresh + ? "connection.actions.refresh" + : "connection.actions.clear"; + return ( + , + }} + /> + ); +}; diff --git a/airbyte-webapp/src/components/connection/ConnectionSync/ConnectionSyncContext.tsx b/airbyte-webapp/src/components/connection/ConnectionSync/ConnectionSyncContext.tsx index 3f16c76c6a7..3b87c5312cc 100644 --- a/airbyte-webapp/src/components/connection/ConnectionSync/ConnectionSyncContext.tsx +++ b/airbyte-webapp/src/components/connection/ConnectionSync/ConnectionSyncContext.tsx @@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, useContext, useMemo } from "react"; import { isClearJob } from "area/connection/utils/jobs"; +import { useInitialStreamSync } from "area/connection/utils/useInitialStreamSync"; import { useSyncConnection, useCancelJob, @@ -21,7 +22,12 @@ import { JobReadList, RefreshMode, } from "core/api/types/AirbyteClient"; +import { Action, Namespace, useAnalyticsService } from "core/services/analytics"; +import { useConfirmationModalService } from "hooks/services/ConfirmationModal"; import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService"; +import { useExperiment } from "hooks/services/Experiment"; + +import { CancelJobModalBody } from "./CancelJobModalBody"; interface ConnectionSyncContext { syncConnection: () => Promise; @@ -29,7 +35,7 @@ interface ConnectionSyncContext { connectionEnabled: boolean; syncStarting: boolean; jobSyncRunning: boolean; - cancelJob: (() => Promise) | undefined; + cancelJob: (() => void) | undefined; cancelStarting: boolean; refreshStreams: ({ streams, @@ -55,6 +61,10 @@ const useConnectionSyncContextInit = (connection: WebBackendConnectionRead): Con const mostRecentJob = jobs?.[0]?.job; const connectionEnabled = connection.status === ConnectionStatus.active; const queryClient = useQueryClient(); + const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService(); + const analyticsService = useAnalyticsService(); + const showCancellationConfirmation = useExperiment("connection.jobCancellationModal", false); + const { streamsSyncingForFirstTime, isConnectionInitialSync } = useInitialStreamSync(connection.connectionId); const { mutateAsync: doSyncConnection, isLoading: syncStarting } = useSyncConnection(); const syncConnection = useCallback(async () => { @@ -62,6 +72,7 @@ const useConnectionSyncContextInit = (connection: WebBackendConnectionRead): Con }, [connection, doSyncConnection]); const { mutateAsync: doCancelJob, isLoading: cancelStarting } = useCancelJob(); + const cancelJob = useMemo(() => { const jobId = mostRecentJob?.id; if (!jobId) { @@ -81,6 +92,87 @@ const useConnectionSyncContextInit = (connection: WebBackendConnectionRead): Con }; }, [mostRecentJob, doCancelJob, connection.connectionId, queryClient]); + const cancelJobWithConfirmationModal = useCallback(() => { + const jobId = mostRecentJob?.id; + + if (!jobId) { + return undefined; + } + + const isClear = mostRecentJob?.configType === "clear" || mostRecentJob?.configType === "reset_connection"; + + const handleCancel = () => { + doCancelJob(jobId); + + queryClient.setQueriesData( + jobsKeys.useListJobsForConnectionStatus(connection.connectionId), + (prevJobList) => + prependArtificialJobToStatus( + { configType: mostRecentJob?.configType ?? "sync", status: JobStatus.cancelled }, + prevJobList + ) + ); + queryClient.invalidateQueries(connectionsKeys.syncProgress(connection.connectionId)); + }; + + return openConfirmationModal({ + title: "connection.actions.cancel.confirm.title", + text: ( + + ), + + submitButtonText: isClear + ? "connection.actions.cancel.clear.confirm.submit" + : "connection.actions.cancel.confirm.submit", + cancelButtonText: isClear + ? "connection.actions.cancel.clear.confirm.cancel" + : "connection.actions.cancel.confirm.cancel", + + onCancel: () => { + analyticsService.track(Namespace.CONNECTION, Action.DECLINED_CANCEL_SYNC, { + actionDescription: "Closed modal without cancelling sync", + connector_source: connection.source?.sourceName, + connector_source_definition_id: connection.source?.sourceDefinitionId, + connector_destination: connection.destination?.destinationName, + connector_destination_definition_id: connection.destination?.destinationDefinitionId, + job_id: jobId, + config_type: mostRecentJob.configType, + }); + }, + onSubmit: async () => { + analyticsService.track(Namespace.CONNECTION, Action.CONFIRMED_CANCEL_SYNC, { + actionDescription: "Canceled sync from confirmation modal", + connector_source: connection.source?.sourceName, + connector_source_definition_id: connection.source?.sourceDefinitionId, + connector_destination: connection.destination?.destinationName, + connector_destination_definition_id: connection.destination?.destinationDefinitionId, + job_id: jobId, + config_type: mostRecentJob.configType, + }); + handleCancel(); + closeConfirmationModal(); + }, + }); + }, [ + mostRecentJob, + openConfirmationModal, + connection.connectionId, + connection.source?.sourceName, + connection.source?.sourceDefinitionId, + connection.destination?.destinationName, + connection.destination?.destinationDefinitionId, + streamsSyncingForFirstTime, + isConnectionInitialSync, + doCancelJob, + queryClient, + analyticsService, + closeConfirmationModal, + ]); + const { mutateAsync: doResetConnection, isLoading: clearStarting } = useClearConnection(); const { mutateAsync: resetStream } = useClearConnectionStream(connection.connectionId); const { mutateAsync: refreshStreams, isLoading: refreshStarting } = useRefreshConnectionStreams( @@ -138,7 +230,7 @@ const useConnectionSyncContextInit = (connection: WebBackendConnectionRead): Con connectionEnabled, syncStarting, jobSyncRunning, - cancelJob, + cancelJob: showCancellationConfirmation ? cancelJobWithConfirmationModal : cancelJob, cancelStarting, refreshStreams, refreshStarting, diff --git a/airbyte-webapp/src/core/services/analytics/types.ts b/airbyte-webapp/src/core/services/analytics/types.ts index a6c3f42ac77..94da34856ce 100644 --- a/airbyte-webapp/src/core/services/analytics/types.ts +++ b/airbyte-webapp/src/core/services/analytics/types.ts @@ -48,6 +48,8 @@ export const enum Action { APPLIED = "Applied", SET_SYNC_MODE = "SetSyncMode", DISMISSED_CHANGES_MODAL = "DismissedChangesModal", + CONFIRMED_CANCEL_SYNC = "ConfirmedCancelSync", + DECLINED_CANCEL_SYNC = "DeclinedCancelSync", SYNC_PROGRESS = "SyncProgress", // Connector Builder Actions diff --git a/airbyte-webapp/src/hooks/services/Experiment/experiments.ts b/airbyte-webapp/src/hooks/services/Experiment/experiments.ts index 83688462552..8fceb16f376 100644 --- a/airbyte-webapp/src/hooks/services/Experiment/experiments.ts +++ b/airbyte-webapp/src/hooks/services/Experiment/experiments.ts @@ -19,6 +19,7 @@ export interface Experiments { "connection.streamCentricUI.v2": boolean; "connection.streamCentricUI.historicalOverview": boolean; "connection.timeline": boolean; + "connection.jobCancellationModal": boolean; "connector.airbyteCloudIpAddresses": string; "connector.suggestedSourceConnectors": string; "connector.suggestedDestinationConnectors": string; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index 0aa44d86e71..63af2d7d1b1 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -678,6 +678,22 @@ "connection.actions.clearDataDescription": "Clearing your data will delete all data in your destination.", "connection.actions.clearData.confirm.text": "Clearing data for this connection will delete all data in your destination for this connection. The next sync will sync all historical data.", "connection.actions.clearData.confirm.title": "Are you sure you want to clear data from this connection?", + "connection.actions.cancel.confirm.title": "Are you sure you wish to cancel?", + "connection.actions.sync": "sync", + "connection.actions.clear": "clear", + "connection.actions.refresh": "refresh", + "connection.actions.cancel.body.generic": "This will cancel the current {configType}.", + "connection.actions.cancel.confirm.body.initialSync": "This connection is syncing for the first time and may a little take longer.", + "connection.actions.cancel.confirm.body.streamInitialSync": "The {streamValues} {count, plural, one {stream is} other {streams are}} syncing for the first time and may take a little longer.", + "connection.actions.cancel.confirm.body.starting": "This sync is still getting started.", + "connection.actions.cancel.confirm.body.extractedOnly": "This sync has extracted {recordsExtracted} records so far.", + "connection.actions.cancel.confirm.body.loaded": "This sync has loaded {recordsLoaded} records so far.", + + "connection.actions.cancel.confirm.submit": "Yes, cancel sync", + "connection.actions.cancel.confirm.cancel": "No, continue sync", + "connection.actions.cancel.clear.confirm.submit": "Yes, cancel clear", + "connection.actions.cancel.clear.confirm.cancel": "No, continue clear", + "connection.timeline": "Timeline", "connection.timeline.error": "Error: ", "connection.timeline.warning": "Warning: ", diff --git a/airbyte-webapp/src/pages/connections/ConnectionSettingsPage/ConnectionSettingsPage.test.tsx b/airbyte-webapp/src/pages/connections/ConnectionSettingsPage/ConnectionSettingsPage.test.tsx index 51095f69ad5..51c4a9bcd5f 100644 --- a/airbyte-webapp/src/pages/connections/ConnectionSettingsPage/ConnectionSettingsPage.test.tsx +++ b/airbyte-webapp/src/pages/connections/ConnectionSettingsPage/ConnectionSettingsPage.test.tsx @@ -32,6 +32,9 @@ jest.mock("core/api", () => ({ mutateAsync: async (connection: WebBackendConnectionUpdate) => connection, isLoading: false, }), + useGetConnectionSyncProgress: () => { + return []; + }, useSourceDefinitionVersion: () => mockSourceDefinitionVersion, useDestinationDefinitionVersion: () => mockDestinationDefinitionVersion, useGetSourceDefinitionSpecification: () => mockSourceDefinitionSpecification,