fix: update saving behavior when exiting flow (#3673)

* Updated save flow to only execute when the flow has unsaved changes

* Made flowPage call saveFlow immediatly when trying to exit page

* Changed save changes modal to automatically exit when saved on autoSave

* Changed flowPage to save and exit when autoSave

* Remove confirmation button if it does not exist and remove footer if confirmation and cancel does not exist

* Changed saveChangesModal to not show buttons when autoSave is on and to set loading as true when autoSave is off and the user saves

* Add timer to close modal, so, if saving takes less than 1 second, it waits

* Changed type of confirmationModal

* Added handling for when it's building and there is unsaved changes

* Added success toast when flow finishes saving

* refactored confirmationModal

* check autoLogin !== undefined before calling things

* Fix use save flow to compare the passed flow instead of the current one

* Changed style of save changes modal

* deleted build in progress modal

* Removed build in progress modal, just stopping the build

* Fix condition on api

* Changed condition to be autoLogin !== undefined
This commit is contained in:
Lucas Oliveira 2024-09-05 18:10:15 -03:00 committed by GitHub
commit 3bc9cc9686
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 176 additions and 148 deletions

View file

@ -24,13 +24,13 @@ export const ProtectedRoute = ({ children }) => {
mutateRefresh();
};
if (!autoLogin && isAuthenticated) {
if (autoLogin !== undefined && !autoLogin && isAuthenticated) {
const intervalId = setInterval(intervalFunction, accessTokenTimer * 1000);
intervalFunction();
return () => clearInterval(intervalId);
}
}, [isAuthenticated]);
if (!isAuthenticated && !autoLogin) {
if (!isAuthenticated && autoLogin !== undefined && !autoLogin) {
return <CustomNavigate to="/login" replace />;
} else {
return children;

View file

@ -2,7 +2,6 @@ import { LANGFLOW_ACCESS_TOKEN } from "@/constants/constants";
import { useCustomApiHeaders } from "@/customization/hooks/use-custom-api-headers";
import useAuthStore from "@/stores/authStore";
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
import pako from "pako";
import { useContext, useEffect } from "react";
import { Cookies } from "react-cookie";
import { BuildStatus } from "../../constants/enums";
@ -35,7 +34,7 @@ function ApiInterceptor() {
error?.response?.status === 403 || error?.response?.status === 401;
if (isAuthenticationError) {
if (!autoLogin) {
if (autoLogin !== undefined && !autoLogin) {
if (error?.config?.url?.includes("github")) {
return Promise.reject(error);
}
@ -129,7 +128,7 @@ function ApiInterceptor() {
api.interceptors.response.eject(interceptor);
api.interceptors.request.eject(requestInterceptor);
};
}, [accessToken, setErrorData, customHeaders]);
}, [accessToken, setErrorData, customHeaders, autoLogin]);
function checkErrorCount() {
if (isLoginPage) return;

View file

@ -3,6 +3,7 @@ import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import useFlowStore from "@/stores/flowStore";
import { FlowType } from "@/types/flow";
import { customStringify } from "@/utils/reactflowUtils";
const useSaveFlow = () => {
const flows = useFlowsManagerStore((state) => state.flows);
@ -21,73 +22,78 @@ const useSaveFlow = () => {
const { mutate } = usePatchUpdateFlow();
const saveFlow = async (flow?: FlowType): Promise<void> => {
setSaveLoading(true);
return new Promise<void>((resolve, reject) => {
if (currentFlow) {
flow = flow || {
...currentFlow,
data: {
...flowData,
nodes,
edges,
viewport: reactFlowInstance?.getViewport() ?? {
zoom: 1,
x: 0,
y: 0,
if (
customStringify(flow || currentFlow) !== customStringify(currentSavedFlow)
) {
setSaveLoading(true);
return new Promise<void>((resolve, reject) => {
if (currentFlow) {
flow = flow || {
...currentFlow,
data: {
...flowData,
nodes,
edges,
viewport: reactFlowInstance?.getViewport() ?? {
zoom: 1,
x: 0,
y: 0,
},
},
},
};
}
if (flow && flow.data) {
const { id, name, data, description, folder_id, endpoint_name } = flow;
if (!currentSavedFlow?.data?.nodes.length || data.nodes.length > 0) {
mutate(
{ id, name, data, description, folder_id, endpoint_name },
{
onSuccess: (updatedFlow) => {
setSaveLoading(false);
if (flows) {
// updates flow in state
setFlows(
flows.map((flow) => {
if (flow.id === updatedFlow.id) {
return updatedFlow;
}
return flow;
}),
);
setCurrentFlow(updatedFlow);
resolve();
} else {
};
}
if (flow && flow.data) {
const { id, name, data, description, folder_id, endpoint_name } =
flow;
if (!currentSavedFlow?.data?.nodes.length || data.nodes.length > 0) {
mutate(
{ id, name, data, description, folder_id, endpoint_name },
{
onSuccess: (updatedFlow) => {
setSaveLoading(false);
if (flows) {
// updates flow in state
setFlows(
flows.map((flow) => {
if (flow.id === updatedFlow.id) {
return updatedFlow;
}
return flow;
}),
);
setCurrentFlow(updatedFlow);
resolve();
} else {
setErrorData({
title: "Failed to save flow",
list: ["Flows variable undefined"],
});
reject(new Error("Flows variable undefined"));
}
},
onError: (e) => {
setErrorData({
title: "Failed to save flow",
list: ["Flows variable undefined"],
list: [e.message],
});
reject(new Error("Flows variable undefined"));
}
setSaveLoading(false);
reject(e);
},
},
onError: (e) => {
setErrorData({
title: "Failed to save flow",
list: [e.message],
});
setSaveLoading(false);
reject(e);
},
},
);
);
} else {
setSaveLoading(false);
reject(new Error("Can't save empty flow"));
}
} else {
setSaveLoading(false);
reject(new Error("Can't save empty flow"));
setErrorData({
title: "Failed to save flow",
list: ["Flow not found"],
});
reject(new Error("Flow not found"));
}
} else {
setErrorData({
title: "Failed to save flow",
list: ["Flow not found"],
});
reject(new Error("Flow not found"));
}
});
});
}
};
return saveFlow;

View file

@ -1,26 +0,0 @@
import ConfirmationModal from "../confirmationModal";
export function BuildInProgressModal({
onStopBuild,
onCancel,
}: {
onStopBuild: () => void;
onCancel: () => void;
}): JSX.Element {
return (
<ConfirmationModal
open={true}
onClose={onCancel}
title="Build in Progress"
cancelText="Cancel"
confirmationText="Stop Build"
onConfirm={onStopBuild}
onCancel={onCancel}
size="x-small"
>
<ConfirmationModal.Content>
The flow is currently building. Do you want to stop the build and exit?
</ConfirmationModal.Content>
</ConfirmationModal>
);
}

View file

@ -66,6 +66,10 @@ function ConfirmationModal({
(child) => (child as React.ReactElement).type === Content,
);
const shouldShowConfirm = confirmationText && onConfirm;
const shouldShowCancel = cancelText;
const shouldShowFooter = shouldShowConfirm || shouldShowCancel;
return (
<BaseModal {...props} open={open} setOpen={setModalOpen}>
<BaseModal.Trigger>{triggerChild}</BaseModal.Trigger>
@ -89,34 +93,40 @@ function ConfirmationModal({
{ContentChild}
</BaseModal.Content>
<BaseModal.Footer>
<Button
className="ml-3"
variant={destructive ? "destructive" : "default"}
onClick={() => {
setFlag(true);
setModalOpen(false);
onConfirm(index, data);
}}
loading={loading}
data-testid="replace-button"
>
{confirmationText}
</Button>
{cancelText && onCancel && (
<Button
className=""
variant={destructiveCancel ? "destructive" : "outline"}
onClick={() => {
setFlag(true);
if (onCancel) onCancel();
setModalOpen(false);
}}
>
{cancelText}
</Button>
)}
</BaseModal.Footer>
{shouldShowFooter ? (
<BaseModal.Footer>
{shouldShowConfirm && (
<Button
className="ml-3"
variant={destructive ? "destructive" : "default"}
onClick={() => {
setFlag(true);
setModalOpen(false);
onConfirm(index, data);
}}
loading={loading}
data-testid="replace-button"
>
{confirmationText}
</Button>
)}
{shouldShowCancel && (
<Button
className=""
variant={destructiveCancel ? "destructive" : "outline"}
onClick={() => {
setFlag(true);
setModalOpen(false);
onCancel?.();
}}
>
{cancelText}
</Button>
)}
</BaseModal.Footer>
) : (
<></>
)}
</BaseModal>
);
}

View file

@ -1,5 +1,7 @@
import ForwardedIconComponent from "@/components/genericIconComponent";
import Loading from "@/components/ui/loading";
import { truncate } from "lodash";
import { useState } from "react";
import ConfirmationModal from "../confirmationModal";
export function SaveChangesModal({
@ -7,7 +9,6 @@ export function SaveChangesModal({
onProceed,
onCancel,
flowName,
unsavedChanges,
lastSaved,
autoSave,
}: {
@ -15,34 +16,43 @@ export function SaveChangesModal({
onProceed: () => void;
onCancel: () => void;
flowName: string;
unsavedChanges: boolean;
lastSaved: string | undefined;
autoSave: boolean;
}): JSX.Element {
const [saving, setSaving] = useState(false);
return (
<ConfirmationModal
open={true}
onClose={onCancel}
destructiveCancel
title={truncate(flowName, { length: 32 }) + " has unsaved changes"}
title={
(autoSave ? "Flow" : truncate(flowName, { length: 32 })) +
" has unsaved changes"
}
cancelText={autoSave ? undefined : "Exit anyway"}
confirmationText={autoSave ? "Exit" : "Save and Exit"}
onConfirm={autoSave ? onProceed : onSave}
confirmationText={autoSave ? undefined : "Save and Exit"}
onConfirm={
autoSave
? undefined
: () => {
setSaving(true);
onSave();
}
}
onCancel={onProceed}
loading={autoSave ? unsavedChanges : false}
loading={autoSave ? true : saving}
size="x-small"
>
<ConfirmationModal.Content>
{autoSave ? (
unsavedChanges ? (
"Saving flow automatically..."
) : (
"Flow saved! Click 'Exit' to leave the page."
)
<div className="mb-4 flex w-full items-center gap-3 rounded-md bg-gray-100 px-4 py-2 text-gray-800 dark:bg-gray-900/40 dark:text-gray-100">
<Loading className="h-5 w-5" />
Saving your changes...
</div>
) : (
<>
<div className="mb-4 flex w-full items-center gap-3 rounded-md bg-yellow-100 px-4 py-2 text-yellow-800">
<ForwardedIconComponent name="info" className="h-5 w-5" />
<div className="mb-4 flex w-full items-center gap-3 rounded-md bg-yellow-100 px-4 py-2 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-100">
<ForwardedIconComponent name="Info" className="h-5 w-5" />
Last saved: {lastSaved ?? "Never"}
</div>
Unsaved changes will be permanently lost.{" "}

View file

@ -3,12 +3,12 @@ import { ENABLE_BRANDING } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useSaveFlow from "@/hooks/flows/use-save-flow";
import { SaveChangesModal } from "@/modals/saveChangesModal";
import useAlertStore from "@/stores/alertStore";
import { useTypesStore } from "@/stores/typesStore";
import { customStringify } from "@/utils/reactflowUtils";
import { useEffect } from "react";
import { useBlocker, useParams } from "react-router-dom";
import FlowToolbar from "../../components/chatComponent";
import { BuildInProgressModal } from "../../modals/buildInProgressModal";
import { useDarkStore } from "../../stores/darkStore";
import useFlowStore from "../../stores/flowStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
@ -19,6 +19,7 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const setCurrentFlow = useFlowsManagerStore((state) => state.setCurrentFlow);
const currentFlow = useFlowStore((state) => state.currentFlow);
const currentSavedFlow = useFlowsManagerStore((state) => state.currentFlow);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const changesNotSaved =
customStringify(currentFlow) !== customStringify(currentSavedFlow) &&
@ -47,12 +48,26 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const stopBuilding = useFlowStore((state) => state.stopBuilding);
const handleSave = () => {
saveFlow().then(() => (blocker.proceed ? blocker.proceed() : null));
};
const handleStopBuild = () => {
stopBuilding();
if (blocker.proceed) blocker.proceed();
let saving = true;
let proceed = false;
setTimeout(() => {
saving = false;
if (proceed) {
blocker.proceed && blocker.proceed();
setSuccessData({
title: "Flow saved successfully!",
});
}
}, 1200);
saveFlow().then(() => {
if (!autoSaving || saving === false) {
blocker.proceed && blocker.proceed();
setSuccessData({
title: "Flow saved successfully!",
});
}
proceed = true;
});
};
const handleExit = () => {
@ -111,6 +126,27 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
};
}, [id]);
useEffect(() => {
if (
blocker.state === "blocked" &&
autoSaving &&
changesNotSaved &&
!isBuilding
) {
handleSave();
}
}, [blocker.state, isBuilding]);
useEffect(() => {
if (blocker.state === "blocked") {
if (isBuilding) {
stopBuilding();
} else if (!changesNotSaved) {
blocker.proceed && blocker.proceed();
}
}
}, [blocker.state, isBuilding]);
return (
<>
<div className="flow-page-positioning">
@ -140,19 +176,12 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
</div>
{blocker.state === "blocked" && (
<>
{isBuilding && (
<BuildInProgressModal
onStopBuild={handleStopBuild}
onCancel={() => blocker.reset?.()}
/>
)}
{!isBuilding && currentSavedFlow && (
<SaveChangesModal
onSave={handleSave}
onCancel={() => blocker.reset?.()}
onProceed={handleExit}
flowName={currentSavedFlow.name}
unsavedChanges={changesNotSaved}
lastSaved={
updatedAt
? new Date(updatedAt).toLocaleString("en-US", {

View file

@ -440,14 +440,14 @@ export type ConfirmationModalType = {
modalContentTitle?: string;
loading?: boolean;
cancelText?: string;
confirmationText: string;
confirmationText?: string;
children:
| [React.ReactElement<ContentProps>, React.ReactElement<TriggerProps>]
| React.ReactElement<ContentProps>;
icon?: string;
data?: any;
index?: number;
onConfirm: (index, data) => void;
onConfirm?: (index, data) => void;
open?: boolean;
onClose?: () => void;
size?: