Skip to content

Commit

Permalink
feat: add confirmation modal with context when canceling a job (#13015)
Browse files Browse the repository at this point in the history
Co-authored-by: Joey Marshment-Howell <[email protected]>
  • Loading branch information
teallarson and josephkmh committed Jul 17, 2024
1 parent 6950de8 commit 8e344c4
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 2 deletions.
32 changes: 32 additions & 0 deletions airbyte-webapp/src/area/connection/utils/useInitialStreamSync.ts
Original file line number Diff line number Diff line change
@@ -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()),
};
};
Original file line number Diff line number Diff line change
@@ -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<CancelJobModalBodyProps> = ({
isConnectionInitialSync,
streamsSyncingForFirstTime,
configType,
}) => {
if (isConnectionInitialSync) {
return <FormattedMessage id="connection.actions.cancel.confirm.body.initialSync" />;
}

if (streamsSyncingForFirstTime.length === 1) {
const streamValues = streamsSyncingForFirstTime.map((stream) => {
return (
<Text key={stream} as="span" size="lg">
{stream}
</Text>
);
});

return (
<FormattedMessage
id="connection.actions.cancel.confirm.body.streamInitialSync"
values={{ streamValues, count: streamValues.length }}
/>
);
}
const configTypeId =
configType === JobConfigType.sync
? "connection.actions.sync"
: configType === JobConfigType.refresh
? "connection.actions.refresh"
: "connection.actions.clear";
return (
<FormattedMessage
id="connection.actions.cancel.body.generic"
values={{
configType: <FormattedMessage id={configTypeId} />,
}}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,15 +22,20 @@ 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<void>;
isSyncConnectionAvailable: boolean;
connectionEnabled: boolean;
syncStarting: boolean;
jobSyncRunning: boolean;
cancelJob: (() => Promise<void>) | undefined;
cancelJob: (() => void) | undefined;
cancelStarting: boolean;
refreshStreams: ({
streams,
Expand All @@ -55,13 +61,18 @@ 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 () => {
doSyncConnection(connection);
}, [connection, doSyncConnection]);

const { mutateAsync: doCancelJob, isLoading: cancelStarting } = useCancelJob();

const cancelJob = useMemo(() => {
const jobId = mostRecentJob?.id;
if (!jobId) {
Expand All @@ -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<JobReadList>(
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: (
<CancelJobModalBody
streamsSyncingForFirstTime={streamsSyncingForFirstTime}
isConnectionInitialSync={isConnectionInitialSync}
configType={mostRecentJob?.configType}
/>
),

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(
Expand Down Expand Up @@ -138,7 +230,7 @@ const useConnectionSyncContextInit = (connection: WebBackendConnectionRead): Con
connectionEnabled,
syncStarting,
jobSyncRunning,
cancelJob,
cancelJob: showCancellationConfirmation ? cancelJobWithConfirmationModal : cancelJob,
cancelStarting,
refreshStreams,
refreshStarting,
Expand Down
2 changes: 2 additions & 0 deletions airbyte-webapp/src/core/services/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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: ",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jest.mock("core/api", () => ({
mutateAsync: async (connection: WebBackendConnectionUpdate) => connection,
isLoading: false,
}),
useGetConnectionSyncProgress: () => {
return [];
},
useSourceDefinitionVersion: () => mockSourceDefinitionVersion,
useDestinationDefinitionVersion: () => mockDestinationDefinitionVersion,
useGetSourceDefinitionSpecification: () => mockSourceDefinitionSpecification,
Expand Down

0 comments on commit 8e344c4

Please sign in to comment.