Folders, Persistent Import Progress Tracking & UX Enhancements (#3841)
Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { WorkflowApiResponse } from "../types/workflowTypes";
|
||||
import { useActiveImportsQuery } from "./useActiveImportsQuery";
|
||||
|
||||
export function useActiveImportsPolling() {
|
||||
const queryClient = useQueryClient();
|
||||
const previousCountRef = useRef<number>(0);
|
||||
const [shouldPoll, setShouldPoll] = useState(true); // Start with true to check on mount
|
||||
const justStartedPollingRef = useRef(false); // Prevent immediate stop after starting
|
||||
const seenFailuresRef = useRef<Set<string>>(new Set()); // Track failures we've already shown
|
||||
const seenCompletionsRef = useRef<Set<string>>(new Set()); // Track completions we've already shown
|
||||
const previousImportsRef = useRef<Map<string, WorkflowApiResponse>>(
|
||||
new Map(),
|
||||
); // Track previous state
|
||||
|
||||
const { data: activeImports = [] } = useActiveImportsQuery({
|
||||
enabled: shouldPoll,
|
||||
refetchInterval: shouldPoll ? 3000 : false,
|
||||
});
|
||||
|
||||
// Monitor for completed/failed imports and invalidate workflows when count decreases
|
||||
useEffect(() => {
|
||||
const currentCount = activeImports.length;
|
||||
const previousCount = previousCountRef.current;
|
||||
const importingCount = activeImports.filter(
|
||||
(imp) => imp.status === "importing",
|
||||
).length;
|
||||
|
||||
// Reset completion/failure tracking when an import restarts
|
||||
activeImports.forEach((imp) => {
|
||||
if (imp.status === "importing") {
|
||||
seenCompletionsRef.current.delete(imp.workflow_permanent_id);
|
||||
seenFailuresRef.current.delete(imp.workflow_permanent_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for status changes and disappeared workflows
|
||||
const currentPermanentIds = new Set(
|
||||
activeImports.map((imp) => imp.workflow_permanent_id),
|
||||
);
|
||||
|
||||
// Check for workflows that disappeared (importing -> completed successfully)
|
||||
previousImportsRef.current.forEach((prevImport, permanentId) => {
|
||||
// If it was importing and now it's gone, it completed successfully!
|
||||
if (
|
||||
prevImport.status === "importing" &&
|
||||
!currentPermanentIds.has(permanentId) &&
|
||||
!seenCompletionsRef.current.has(permanentId)
|
||||
) {
|
||||
seenCompletionsRef.current.add(permanentId);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Workflow imported",
|
||||
description: `Successfully imported ${prevImport.title || "workflow"}`,
|
||||
});
|
||||
|
||||
// Refresh workflows to show new workflow
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
}
|
||||
});
|
||||
|
||||
// Check for failed imports (status changed from importing → import_failed)
|
||||
activeImports.forEach((imp) => {
|
||||
const previousImport = previousImportsRef.current.get(
|
||||
imp.workflow_permanent_id,
|
||||
);
|
||||
|
||||
// Only show toast if we SAW the transition from importing → import_failed
|
||||
if (
|
||||
imp.status === "import_failed" &&
|
||||
previousImport?.status === "importing" &&
|
||||
!seenFailuresRef.current.has(imp.workflow_permanent_id)
|
||||
) {
|
||||
seenFailuresRef.current.add(imp.workflow_permanent_id);
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Import failed",
|
||||
description:
|
||||
imp.import_error || `Failed to import ${imp.title || "workflow"}`,
|
||||
});
|
||||
|
||||
// Refresh workflows to update UI
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
}
|
||||
});
|
||||
|
||||
// Update previous imports map for next comparison
|
||||
previousImportsRef.current = new Map(
|
||||
activeImports.map((imp) => [imp.workflow_permanent_id, imp]),
|
||||
);
|
||||
|
||||
// If we have active IMPORTING imports, make sure polling is enabled
|
||||
if (importingCount > 0 && !shouldPoll) {
|
||||
setShouldPoll(true);
|
||||
}
|
||||
|
||||
// Stop polling if there are no IMPORTING imports
|
||||
// BUT don't stop if we just started polling (to avoid race condition)
|
||||
if (importingCount === 0 && shouldPoll && !justStartedPollingRef.current) {
|
||||
setShouldPoll(false);
|
||||
}
|
||||
|
||||
// Clear the "just started" flag once we have imports or if count changed
|
||||
if (
|
||||
justStartedPollingRef.current &&
|
||||
(currentCount > 0 || currentCount !== previousCount)
|
||||
) {
|
||||
justStartedPollingRef.current = false;
|
||||
}
|
||||
|
||||
previousCountRef.current = currentCount;
|
||||
}, [activeImports, queryClient, shouldPoll]);
|
||||
|
||||
// Function to start polling (called when a new import starts)
|
||||
const startPolling = () => {
|
||||
justStartedPollingRef.current = true; // Set flag to prevent immediate stop
|
||||
setShouldPoll(true);
|
||||
// Force refetch immediately to get the latest state
|
||||
queryClient.refetchQueries({ queryKey: ["active-imports"] });
|
||||
queryClient.refetchQueries({ queryKey: ["workflows"] });
|
||||
};
|
||||
|
||||
return { activeImports, startPolling };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkflowApiResponse } from "../types/workflowTypes";
|
||||
|
||||
type UseActiveImportsQueryParams = {
|
||||
enabled?: boolean;
|
||||
refetchInterval?: number | false;
|
||||
};
|
||||
|
||||
export function useActiveImportsQuery({
|
||||
enabled = true,
|
||||
refetchInterval = false,
|
||||
}: UseActiveImportsQueryParams = {}) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["active-imports"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const response = await client.get<WorkflowApiResponse[]>("/workflows", {
|
||||
params: {
|
||||
status: ["importing", "import_failed"],
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
paramsSerializer: {
|
||||
indexes: null, // Remove brackets from array params: status=a&status=b instead of status[]=a&status[]=b
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled,
|
||||
refetchInterval,
|
||||
refetchIntervalInBackground: true,
|
||||
staleTime: 0, // Always consider data stale so it refetches immediately
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import type {
|
||||
Folder,
|
||||
FolderCreate,
|
||||
FolderUpdate,
|
||||
UpdateWorkflowFolderRequest,
|
||||
} from "../types/folderTypes";
|
||||
|
||||
function useCreateFolderMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: FolderCreate) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.post<Folder>("/folders", data)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create folder",
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateFolderMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
folderId,
|
||||
data,
|
||||
}: {
|
||||
folderId: string;
|
||||
data: FolderUpdate;
|
||||
}) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.put<Folder>(`/folders/${folderId}`, data)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update folder",
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useDeleteFolderMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
folderId,
|
||||
deleteWorkflows,
|
||||
}: {
|
||||
folderId: string;
|
||||
folderTitle: string;
|
||||
deleteWorkflows: boolean;
|
||||
}) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client.delete(`/folders/${folderId}`, {
|
||||
params: {
|
||||
delete_workflows: deleteWorkflows,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to delete folder",
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateWorkflowFolderMutation() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowPermanentId,
|
||||
data,
|
||||
}: {
|
||||
workflowPermanentId: string;
|
||||
data: UpdateWorkflowFolderRequest;
|
||||
}) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client.put(`/workflows/${workflowPermanentId}/folder`, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folders"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
useCreateFolderMutation,
|
||||
useUpdateFolderMutation,
|
||||
useDeleteFolderMutation,
|
||||
useUpdateWorkflowFolderMutation,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Folder } from "../types/folderTypes";
|
||||
|
||||
interface UseFoldersQueryParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
function useFoldersQuery(params?: UseFoldersQueryParams) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["folders", params],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) {
|
||||
searchParams.append("page", String(params.page));
|
||||
}
|
||||
if (params?.page_size) {
|
||||
searchParams.append("page_size", String(params.page_size));
|
||||
}
|
||||
if (params?.search) {
|
||||
searchParams.append("search", params.search);
|
||||
}
|
||||
|
||||
return client
|
||||
.get<Array<Folder>>("/folders", { params: searchParams })
|
||||
.then((response) => response.data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { useFoldersQuery };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { Folder } from "../types/folderTypes";
|
||||
|
||||
interface UseInfiniteFoldersQueryParams {
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
function useInfiniteFoldersQuery(params?: UseInfiniteFoldersQueryParams) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["folders", "infinite", params],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.append("page", String(pageParam));
|
||||
|
||||
if (params?.page_size) {
|
||||
searchParams.append("page_size", String(params.page_size));
|
||||
}
|
||||
if (params?.search) {
|
||||
searchParams.append("search", params.search);
|
||||
}
|
||||
|
||||
return client
|
||||
.get<Array<Folder>>("/folders", { params: searchParams })
|
||||
.then((response) => response.data);
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has items equal to page_size, there might be more
|
||||
const pageSize = params?.page_size || 10;
|
||||
if (lastPage.length === pageSize) {
|
||||
return allPages.length + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export { useInfiniteFoldersQuery };
|
||||
Reference in New Issue
Block a user