Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
cancel submission added
  • Loading branch information
Rishi authored and Rishi committed Nov 25, 2025
commit 3532c89b0b194bb86b8adf98cb8b96fc00fcb018
104 changes: 104 additions & 0 deletions src/backend/base/langflow/api/v1/flow_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,109 @@ async def reject_version(
)


@router.post("/cancel/{version_id}", response_model=FlowVersionRead)
async def cancel_submission(
version_id: UUID,
session: DbSession,
current_user: CurrentActiveUser,
):
"""
Cancel a submitted flow version.

Changes status from "Submitted" back to "Draft" and unlocks the flow.
Only the flow owner can cancel their own submission.
"""
try:
# 1. Fetch the flow version
stmt = (
select(FlowVersion)
.where(FlowVersion.id == version_id)
.options(joinedload(FlowVersion.status))
)
result = await session.exec(stmt)
flow_version = result.first()

if not flow_version:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Flow version not found",
)

# 2. Verify user is the submitter
if flow_version.submitted_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the submitter can cancel this submission",
)

# 3. Verify current status is "Submitted"
if flow_version.status and flow_version.status.status_name != FlowStatusEnum.SUBMITTED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Can only cancel flows in 'Submitted' status. Current status: {flow_version.status.status_name}",
)

# 4. Get "Draft" status ID
draft_status_id = await _get_status_id_by_name(session, FlowStatusEnum.DRAFT.value)

# 5. Update the flow version - revert to draft
flow_version.status_id = draft_status_id
flow_version.reviewed_by = None
flow_version.reviewed_by_name = None
flow_version.reviewed_by_email = None
flow_version.reviewed_at = None
flow_version.rejection_reason = None
flow_version.updated_at = datetime.now(timezone.utc)

# 6. Unlock the original flow so user can edit
original_flow = await session.get(Flow, flow_version.original_flow_id)
if original_flow:
original_flow.locked = False
session.add(original_flow)

await session.commit()
await session.refresh(flow_version)

logger.info(f"Flow version {version_id} submission cancelled by user {current_user.id}")

return FlowVersionRead(
id=flow_version.id,
original_flow_id=flow_version.original_flow_id,
version_flow_id=flow_version.version_flow_id,
status_id=flow_version.status_id,
version=flow_version.version,
title=flow_version.title,
description=flow_version.description,
tags=flow_version.tags,
agent_logo=flow_version.agent_logo,
sample_id=flow_version.sample_id,
submitted_by=flow_version.submitted_by,
submitted_by_name=flow_version.submitted_by_name,
submitted_by_email=flow_version.submitted_by_email,
submitted_at=flow_version.submitted_at,
reviewed_by=flow_version.reviewed_by,
reviewed_by_name=flow_version.reviewed_by_name,
reviewed_by_email=flow_version.reviewed_by_email,
reviewed_at=flow_version.reviewed_at,
rejection_reason=flow_version.rejection_reason,
created_at=flow_version.created_at,
updated_at=flow_version.updated_at,
status_name=FlowStatusEnum.DRAFT.value,
submitter_name=flow_version.submitted_by_name,
submitter_email=flow_version.submitted_by_email,
reviewer_name=flow_version.reviewed_by_name,
)

except HTTPException:
raise
except Exception as e:
logger.error(f"Error cancelling submission: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cancel submission: {str(e)}",
)


@router.get("/{version_id}", response_model=FlowVersionRead)
async def get_version(
version_id: UUID,
Expand Down Expand Up @@ -846,6 +949,7 @@ async def get_flow_latest_status(
"latest_version_id": str(latest_version.id),
"submitted_at": latest_version.submitted_at.isoformat() if latest_version.submitted_at else None,
"reviewed_at": latest_version.reviewed_at.isoformat() if latest_version.reviewed_at else None,
"rejection_reason": latest_version.rejection_reason,
# Data for pre-populating re-submissions
"sample_text": sample_text,
"file_names": file_names,
Expand Down
42 changes: 40 additions & 2 deletions src/frontend/src/components/common/flowStatusBadge/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { cn } from "@/utils/utils";
import { useState } from "react";
import IconComponent from "../genericIconComponent";
import ShadTooltip from "../shadTooltipComponent";

export type FlowStatus =
| "Draft"
Expand All @@ -13,6 +16,7 @@ export type FlowStatus =
interface FlowStatusBadgeProps {
status: FlowStatus;
className?: string;
rejectionReason?: string | null;
}

const statusConfig: Record<
Expand Down Expand Up @@ -49,7 +53,9 @@ const statusConfig: Record<
},
};

export function FlowStatusBadge({ status, className }: FlowStatusBadgeProps) {
export function FlowStatusBadge({ status, className, rejectionReason }: FlowStatusBadgeProps) {
const [showTooltip, setShowTooltip] = useState(false);

if (!status) {
// Show Draft badge for flows with no submissions
return (
Expand All @@ -66,11 +72,43 @@ export function FlowStatusBadge({ status, className }: FlowStatusBadgeProps) {
}

const config = statusConfig[status];
const isRejected = status === "Rejected";
const hasRejectionReason = isRejected && rejectionReason;

if (hasRejectionReason) {
return (
<ShadTooltip
content={rejectionReason}
side="bottom"
open={showTooltip}
setOpen={setShowTooltip}
delayDuration={0}
>
<span
onClick={(e) => {
e.stopPropagation();
setShowTooltip(!showTooltip);
}}
className={cn(
"ml-2 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium cursor-pointer",
config.className,
className
)}
>
{config.label}
<IconComponent
name="Info"
className="h-3.5 w-3.5"
/>
</span>
</ShadTooltip>
);
}

return (
<span
className={cn(
"ml-2 inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
"ml-2 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
config.className,
className
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const MenuBar = memo((): JSX.Element => {
// Fetch flow latest status for the badge
const { data: flowStatusData } = useGetFlowLatestStatus(currentFlowId);
const flowStatus = flowStatusData?.latest_status as FlowStatus;
const rejectionReason = flowStatusData?.rejection_reason;

const { data: folders, isFetched: isFoldersFetched } = useGetFoldersQuery();

Expand Down Expand Up @@ -152,13 +153,13 @@ export const MenuBar = memo((): JSX.Element => {
className="h-3.5 w-3.5"
/>
</div>}
<PopoverTrigger asChild>
<div
className="group relative -mr-5 flex shrink-0 cursor-pointer items-center gap-2 text-sm sm:whitespace-normal"
data-testid="menu_bar_display"
>
{!shouldHideFlowName && (
<>
{!shouldHideFlowName && (
<>
<PopoverTrigger asChild>
<div
className="group relative flex shrink-0 cursor-pointer items-center gap-1.5 text-sm sm:whitespace-normal"
data-testid="menu_bar_display"
>
<span
ref={measureRef}
className="w-fit max-w-[35vw] truncate whitespace-pre text-mmd font-semibold sm:max-w-full sm:text-sm text-white"
Expand All @@ -167,19 +168,19 @@ export const MenuBar = memo((): JSX.Element => {
>
{currentFlowName || "Untitled Flow"}
</span>
<FlowStatusBadge status={flowStatus} />
<IconComponent
name="pencil"
className={cn(
"h-5 w-3.5 -translate-x-2 opacity-0 transition-all",
"h-4 w-4 opacity-0 transition-all",
!openSettings &&
"sm:group-hover:translate-x-0 sm:group-hover:opacity-100",
"sm:group-hover:opacity-100",
)}
/>
</>
)}
</div>
</PopoverTrigger>
</div>
</PopoverTrigger>
<FlowStatusBadge status={flowStatus} rejectionReason={rejectionReason} />
</>
)}
<div className={"ml-5 hidden shrink-0 items-center sm:flex"}>
{!autoSaving && (
<ShadTooltip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
useGetFlowLatestStatus,
useApproveVersion,
useRejectVersion,
useCancelSubmission,
} from "@/controllers/API/queries/flow-versions";
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow";
import { USER_ROLES } from "@/types/auth";
import SubmitForApprovalModal from "@/modals/submitForApprovalModal";
import PublishFlowModal from "@/modals/publishFlowModal";
Expand Down Expand Up @@ -69,10 +71,16 @@ export default function FlowToolbarOptions() {
const latestStatus = flowStatusData?.latest_status;
const latestVersionId = flowStatusData?.latest_version_id;

// Mutations for approve/reject
// Mutations for approve/reject/cancel
const { mutate: approveVersion, isPending: isApproving } =
useApproveVersion();
const { mutate: rejectVersion, isPending: isRejecting } = useRejectVersion();
const { mutate: cancelSubmission, isPending: isCancelling } = useCancelSubmission();
const { mutateAsync: getFlowMutation } = useGetFlow();

// For updating flow after cancel submission
const setCurrentFlow = useFlowsManagerStore((state) => state.setCurrentFlow);
const setCurrentFlowInFlowStore = useFlowStore((state) => state.setCurrentFlow);

// Button visibility logic based on status and ownership
// Submit for Review: Show for Draft, Rejected, Published, Submitted, or no status - ONLY if user is flow owner
Expand Down Expand Up @@ -141,6 +149,35 @@ export default function FlowToolbarOptions() {
);
};

const handleCancelSubmission = () => {
if (!latestVersionId || !currentFlowId) return;

cancelSubmission(latestVersionId, {
onSuccess: async () => {
setSuccessData({ title: `Submission cancelled for "${currentFlowName}"` });

// Refetch the updated flow to get the unlocked state
try {
const updatedFlow = await getFlowMutation({ id: currentFlowId });
if (updatedFlow) {
setCurrentFlow(updatedFlow); // Update flowsManagerStore
setCurrentFlowInFlowStore(updatedFlow); // Update flowStore
}
} catch (error) {
console.error("Failed to refetch flow after cancel:", error);
}
},
onError: (error: any) => {
setErrorData({
title: "Failed to cancel submission",
list: [
error?.response?.data?.detail || error.message || "Unknown error",
],
});
},
});
};

return (
<>
<div className="flex items-center gap-1.5">
Expand Down Expand Up @@ -182,19 +219,33 @@ export default function FlowToolbarOptions() {
)}

{/* Submit for Review Button - show for Agent Developer (not Marketplace Admin), disabled when under review */}
{showSubmitButton && (
{showSubmitButton && !isUnderReview && (
<Button
variant="ghost"
size="xs"
className="!px-2.5 font-normal"
onClick={() => setOpenSubmitModal(true)}
disabled={isUnderReview}
data-testid="submit-for-review-button"
>
Submit for Review
</Button>
)}

{/* Cancel Submission Button - show when status is Submitted and user is flow owner */}
{isFlowOwner && isUnderReview && (
<Button
variant="ghost"
size="xs"
className="!px-2.5 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleCancelSubmission}
disabled={isCancelling}
data-testid="cancel-submission-button"
>
<IconComponent name="XCircle" className="mr-1.5 h-4 w-4" />
Cancel Submission
</Button>
)}

{/* Publish to Marketplace Button - show when status is Approved */}
{showPublishButton && (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./use-get-pending-reviews";
export * from "./use-get-versions-by-status";
export * from "./use-approve-version";
export * from "./use-reject-version";
export * from "./use-cancel-submission";
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface FlowLatestStatusResponse {
latest_version_id?: string | null;
submitted_at?: string | null;
reviewed_at?: string | null;
rejection_reason?: string | null;
// Data for pre-populating re-submissions
sample_text?: string[] | null;
file_names?: string[] | null;
Expand Down
Loading