Folders, Persistent Import Progress Tracking & UX Enhancements (#3841)

Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
Celal Zamanoglu
2025-11-05 18:37:18 +03:00
committed by GitHub
parent fcc3f30ba4
commit 75948053b9
32 changed files with 2886 additions and 538 deletions

View File

@@ -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 };
}

View File

@@ -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
});
}

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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 };