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(); mutateRefresh();
}; };
if (!autoLogin && isAuthenticated) { if (autoLogin !== undefined && !autoLogin && isAuthenticated) {
const intervalId = setInterval(intervalFunction, accessTokenTimer * 1000); const intervalId = setInterval(intervalFunction, accessTokenTimer * 1000);
intervalFunction(); intervalFunction();
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
} }
}, [isAuthenticated]); }, [isAuthenticated]);
if (!isAuthenticated && !autoLogin) { if (!isAuthenticated && autoLogin !== undefined && !autoLogin) {
return <CustomNavigate to="/login" replace />; return <CustomNavigate to="/login" replace />;
} else { } else {
return children; 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 { useCustomApiHeaders } from "@/customization/hooks/use-custom-api-headers";
import useAuthStore from "@/stores/authStore"; import useAuthStore from "@/stores/authStore";
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
import pako from "pako";
import { useContext, useEffect } from "react"; import { useContext, useEffect } from "react";
import { Cookies } from "react-cookie"; import { Cookies } from "react-cookie";
import { BuildStatus } from "../../constants/enums"; import { BuildStatus } from "../../constants/enums";
@ -35,7 +34,7 @@ function ApiInterceptor() {
error?.response?.status === 403 || error?.response?.status === 401; error?.response?.status === 403 || error?.response?.status === 401;
if (isAuthenticationError) { if (isAuthenticationError) {
if (!autoLogin) { if (autoLogin !== undefined && !autoLogin) {
if (error?.config?.url?.includes("github")) { if (error?.config?.url?.includes("github")) {
return Promise.reject(error); return Promise.reject(error);
} }
@ -129,7 +128,7 @@ function ApiInterceptor() {
api.interceptors.response.eject(interceptor); api.interceptors.response.eject(interceptor);
api.interceptors.request.eject(requestInterceptor); api.interceptors.request.eject(requestInterceptor);
}; };
}, [accessToken, setErrorData, customHeaders]); }, [accessToken, setErrorData, customHeaders, autoLogin]);
function checkErrorCount() { function checkErrorCount() {
if (isLoginPage) return; if (isLoginPage) return;

View file

@ -3,6 +3,7 @@ import useAlertStore from "@/stores/alertStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore";
import useFlowStore from "@/stores/flowStore"; import useFlowStore from "@/stores/flowStore";
import { FlowType } from "@/types/flow"; import { FlowType } from "@/types/flow";
import { customStringify } from "@/utils/reactflowUtils";
const useSaveFlow = () => { const useSaveFlow = () => {
const flows = useFlowsManagerStore((state) => state.flows); const flows = useFlowsManagerStore((state) => state.flows);
@ -21,6 +22,9 @@ const useSaveFlow = () => {
const { mutate } = usePatchUpdateFlow(); const { mutate } = usePatchUpdateFlow();
const saveFlow = async (flow?: FlowType): Promise<void> => { const saveFlow = async (flow?: FlowType): Promise<void> => {
if (
customStringify(flow || currentFlow) !== customStringify(currentSavedFlow)
) {
setSaveLoading(true); setSaveLoading(true);
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (currentFlow) { if (currentFlow) {
@ -39,7 +43,8 @@ const useSaveFlow = () => {
}; };
} }
if (flow && flow.data) { if (flow && flow.data) {
const { id, name, data, description, folder_id, endpoint_name } = flow; const { id, name, data, description, folder_id, endpoint_name } =
flow;
if (!currentSavedFlow?.data?.nodes.length || data.nodes.length > 0) { if (!currentSavedFlow?.data?.nodes.length || data.nodes.length > 0) {
mutate( mutate(
{ id, name, data, description, folder_id, endpoint_name }, { id, name, data, description, folder_id, endpoint_name },
@ -88,6 +93,7 @@ const useSaveFlow = () => {
reject(new Error("Flow not found")); reject(new Error("Flow not found"));
} }
}); });
}
}; };
return saveFlow; 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, (child) => (child as React.ReactElement).type === Content,
); );
const shouldShowConfirm = confirmationText && onConfirm;
const shouldShowCancel = cancelText;
const shouldShowFooter = shouldShowConfirm || shouldShowCancel;
return ( return (
<BaseModal {...props} open={open} setOpen={setModalOpen}> <BaseModal {...props} open={open} setOpen={setModalOpen}>
<BaseModal.Trigger>{triggerChild}</BaseModal.Trigger> <BaseModal.Trigger>{triggerChild}</BaseModal.Trigger>
@ -89,7 +93,9 @@ function ConfirmationModal({
{ContentChild} {ContentChild}
</BaseModal.Content> </BaseModal.Content>
{shouldShowFooter ? (
<BaseModal.Footer> <BaseModal.Footer>
{shouldShowConfirm && (
<Button <Button
className="ml-3" className="ml-3"
variant={destructive ? "destructive" : "default"} variant={destructive ? "destructive" : "default"}
@ -103,20 +109,24 @@ function ConfirmationModal({
> >
{confirmationText} {confirmationText}
</Button> </Button>
{cancelText && onCancel && ( )}
{shouldShowCancel && (
<Button <Button
className="" className=""
variant={destructiveCancel ? "destructive" : "outline"} variant={destructiveCancel ? "destructive" : "outline"}
onClick={() => { onClick={() => {
setFlag(true); setFlag(true);
if (onCancel) onCancel();
setModalOpen(false); setModalOpen(false);
onCancel?.();
}} }}
> >
{cancelText} {cancelText}
</Button> </Button>
)} )}
</BaseModal.Footer> </BaseModal.Footer>
) : (
<></>
)}
</BaseModal> </BaseModal>
); );
} }

View file

@ -1,5 +1,7 @@
import ForwardedIconComponent from "@/components/genericIconComponent"; import ForwardedIconComponent from "@/components/genericIconComponent";
import Loading from "@/components/ui/loading";
import { truncate } from "lodash"; import { truncate } from "lodash";
import { useState } from "react";
import ConfirmationModal from "../confirmationModal"; import ConfirmationModal from "../confirmationModal";
export function SaveChangesModal({ export function SaveChangesModal({
@ -7,7 +9,6 @@ export function SaveChangesModal({
onProceed, onProceed,
onCancel, onCancel,
flowName, flowName,
unsavedChanges,
lastSaved, lastSaved,
autoSave, autoSave,
}: { }: {
@ -15,34 +16,43 @@ export function SaveChangesModal({
onProceed: () => void; onProceed: () => void;
onCancel: () => void; onCancel: () => void;
flowName: string; flowName: string;
unsavedChanges: boolean;
lastSaved: string | undefined; lastSaved: string | undefined;
autoSave: boolean; autoSave: boolean;
}): JSX.Element { }): JSX.Element {
const [saving, setSaving] = useState(false);
return ( return (
<ConfirmationModal <ConfirmationModal
open={true} open={true}
onClose={onCancel} onClose={onCancel}
destructiveCancel destructiveCancel
title={truncate(flowName, { length: 32 }) + " has unsaved changes"} title={
(autoSave ? "Flow" : truncate(flowName, { length: 32 })) +
" has unsaved changes"
}
cancelText={autoSave ? undefined : "Exit anyway"} cancelText={autoSave ? undefined : "Exit anyway"}
confirmationText={autoSave ? "Exit" : "Save and Exit"} confirmationText={autoSave ? undefined : "Save and Exit"}
onConfirm={autoSave ? onProceed : onSave} onConfirm={
autoSave
? undefined
: () => {
setSaving(true);
onSave();
}
}
onCancel={onProceed} onCancel={onProceed}
loading={autoSave ? unsavedChanges : false} loading={autoSave ? true : saving}
size="x-small" size="x-small"
> >
<ConfirmationModal.Content> <ConfirmationModal.Content>
{autoSave ? ( {autoSave ? (
unsavedChanges ? ( <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">
"Saving flow automatically..." <Loading className="h-5 w-5" />
) : ( Saving your changes...
"Flow saved! Click 'Exit' to leave the page." </div>
)
) : ( ) : (
<> <>
<div className="mb-4 flex w-full items-center gap-3 rounded-md bg-yellow-100 px-4 py-2 text-yellow-800"> <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" /> <ForwardedIconComponent name="Info" className="h-5 w-5" />
Last saved: {lastSaved ?? "Never"} Last saved: {lastSaved ?? "Never"}
</div> </div>
Unsaved changes will be permanently lost.{" "} 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 { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useSaveFlow from "@/hooks/flows/use-save-flow"; import useSaveFlow from "@/hooks/flows/use-save-flow";
import { SaveChangesModal } from "@/modals/saveChangesModal"; import { SaveChangesModal } from "@/modals/saveChangesModal";
import useAlertStore from "@/stores/alertStore";
import { useTypesStore } from "@/stores/typesStore"; import { useTypesStore } from "@/stores/typesStore";
import { customStringify } from "@/utils/reactflowUtils"; import { customStringify } from "@/utils/reactflowUtils";
import { useEffect } from "react"; import { useEffect } from "react";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
import FlowToolbar from "../../components/chatComponent"; import FlowToolbar from "../../components/chatComponent";
import { BuildInProgressModal } from "../../modals/buildInProgressModal";
import { useDarkStore } from "../../stores/darkStore"; import { useDarkStore } from "../../stores/darkStore";
import useFlowStore from "../../stores/flowStore"; import useFlowStore from "../../stores/flowStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore"; 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 setCurrentFlow = useFlowsManagerStore((state) => state.setCurrentFlow);
const currentFlow = useFlowStore((state) => state.currentFlow); const currentFlow = useFlowStore((state) => state.currentFlow);
const currentSavedFlow = useFlowsManagerStore((state) => state.currentFlow); const currentSavedFlow = useFlowsManagerStore((state) => state.currentFlow);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const changesNotSaved = const changesNotSaved =
customStringify(currentFlow) !== customStringify(currentSavedFlow) && customStringify(currentFlow) !== customStringify(currentSavedFlow) &&
@ -47,12 +48,26 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const stopBuilding = useFlowStore((state) => state.stopBuilding); const stopBuilding = useFlowStore((state) => state.stopBuilding);
const handleSave = () => { const handleSave = () => {
saveFlow().then(() => (blocker.proceed ? blocker.proceed() : null)); let saving = true;
}; let proceed = false;
setTimeout(() => {
const handleStopBuild = () => { saving = false;
stopBuilding(); if (proceed) {
if (blocker.proceed) blocker.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 = () => { const handleExit = () => {
@ -111,6 +126,27 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
}; };
}, [id]); }, [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 ( return (
<> <>
<div className="flow-page-positioning"> <div className="flow-page-positioning">
@ -140,19 +176,12 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
</div> </div>
{blocker.state === "blocked" && ( {blocker.state === "blocked" && (
<> <>
{isBuilding && (
<BuildInProgressModal
onStopBuild={handleStopBuild}
onCancel={() => blocker.reset?.()}
/>
)}
{!isBuilding && currentSavedFlow && ( {!isBuilding && currentSavedFlow && (
<SaveChangesModal <SaveChangesModal
onSave={handleSave} onSave={handleSave}
onCancel={() => blocker.reset?.()} onCancel={() => blocker.reset?.()}
onProceed={handleExit} onProceed={handleExit}
flowName={currentSavedFlow.name} flowName={currentSavedFlow.name}
unsavedChanges={changesNotSaved}
lastSaved={ lastSaved={
updatedAt updatedAt
? new Date(updatedAt).toLocaleString("en-US", { ? new Date(updatedAt).toLocaleString("en-US", {

View file

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