Просмотр исходного кода

fix(dashboard): Collection contents not showing after saving filters (#4128)

gabriellbui 2 дней назад
Родитель
Сommit
85935132a6

+ 21 - 3
packages/dashboard/src/app/routes/_authenticated/_collections/collections_.$id.tsx

@@ -22,7 +22,9 @@ import {
 } from '@/vdb/framework/layout-engine/page-layout.js';
 import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
 import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { useJobQueuePolling } from '@/vdb/hooks/use-job-queue-polling.js';
 import { Trans, useLingui } from '@lingui/react/macro';
+import { useQueryClient } from '@tanstack/react-query';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
 import {
@@ -54,6 +56,12 @@ function CollectionDetailPage() {
     const navigate = useNavigate();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { t } = useLingui();
+    const queryClient = useQueryClient();
+
+    const { isPolling: pendingFilterApplication, startPolling } = useJobQueuePolling(
+        'apply-collection-filters',
+        () => queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] }),
+    );
 
     const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
         pageId,
@@ -90,12 +98,20 @@ function CollectionDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
+            const filtersWereDirty =
+                form.getFieldState('inheritFilters').isDirty || form.getFieldState('filters').isDirty;
             toast(
                 creatingNewEntity ? t`Successfully created collection` : t`Successfully updated collection`,
             );
             resetForm();
+            if (filtersWereDirty) {
+                startPolling();
+            }
             if (creatingNewEntity) {
-                await navigate({ to: `../$id`, params: { id: data.id } });
+                await navigate({
+                    to: `../$id`,
+                    params: { id: data.id },
+                });
             }
         },
         onError: err => {
@@ -106,7 +122,9 @@ function CollectionDetailPage() {
     });
 
     const shouldPreviewContents =
-        form.getFieldState('inheritFilters').isDirty || form.getFieldState('filters').isDirty;
+        form.getFieldState('inheritFilters').isDirty ||
+        form.getFieldState('filters').isDirty ||
+        pendingFilterApplication;
 
     const currentFiltersValue = form.watch('filters');
     const currentInheritFiltersValue = form.watch('inheritFilters');
@@ -220,7 +238,7 @@ function CollectionDetailPage() {
                     </FormItem>
                 </PageBlock>
                 <PageBlock column="main" blockId="contents" title={<Trans>Contents</Trans>}>
-                    {shouldPreviewContents || creatingNewEntity ? (
+                    {pendingFilterApplication || shouldPreviewContents || creatingNewEntity ? (
                         <CollectionContentsPreviewTable
                             parentId={entity?.parent?.id}
                             filters={currentFiltersValue ?? []}

+ 160 - 0
packages/dashboard/src/lib/hooks/use-job-queue-polling.ts

@@ -0,0 +1,160 @@
+import { api } from '@/vdb/graphql/api.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { useQuery } from '@tanstack/react-query';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const JOB_LOOKBACK_MS = 5000; // Look back 5 seconds to catch jobs created before mutation returned
+const MAX_POLLING_TIMEOUT_MS = 30000;
+const INITIAL_POLL_INTERVAL_MS = 500;
+const MAX_POLL_INTERVAL_MS = 4000;
+const STORAGE_KEY_PREFIX = 'job-queue-polling:';
+
+interface StoredPollingState {
+    startTime: string;
+    expiresAt: number;
+}
+
+const jobListForPollingDocument = graphql(`
+    query JobListForPolling($options: JobListOptions) {
+        jobs(options: $options) {
+            items {
+                id
+                createdAt
+                state
+            }
+            totalItems
+        }
+    }
+`);
+
+const getStorageKey = (queueName: string) => `${STORAGE_KEY_PREFIX}${queueName}`;
+const getStoredState = (queueName: string) => {
+    try {
+        const stored = sessionStorage.getItem(getStorageKey(queueName));
+        if (stored) {
+            return JSON.parse(stored) as StoredPollingState;
+        }
+    } catch {
+        // Ignore parsing errors
+    }
+    return null;
+};
+const setStoredState = (queueName: string, state: StoredPollingState) =>
+    sessionStorage.setItem(getStorageKey(queueName), JSON.stringify(state));
+const clearStoredState = (queueName: string) => sessionStorage.removeItem(getStorageKey(queueName));
+
+/**
+ * Hook to poll a job queue until jobs complete.
+ * Waits for jobs created after polling starts to settle before calling onComplete.
+ *
+ * Polling state is persisted in sessionStorage, allowing it to survive navigation
+ * (e.g., after creating an entity) and page refresh while maintaining the correct
+ * time window for finding relevant jobs.
+ */
+export function useJobQueuePolling(queueName: string, onComplete: () => void) {
+    const [isPolling, setIsPolling] = useState(false);
+    const [pollCount, setPollCount] = useState(0);
+    const startTimeRef = useRef<string | null>(null);
+    const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+    const onCompleteRef = useRef(onComplete);
+    const hasResumedRef = useRef(false);
+
+    useEffect(() => {
+        onCompleteRef.current = onComplete;
+    }, [onComplete]);
+
+    // On mount, check for pending polling state
+    useEffect(() => {
+        if (hasResumedRef.current) return;
+        hasResumedRef.current = true;
+
+        const stored = getStoredState(queueName);
+        if (stored && Date.now() < stored.expiresAt) {
+            startTimeRef.current = stored.startTime;
+            setPollCount(0);
+            setIsPolling(true);
+
+            const remainingTime = stored.expiresAt - Date.now();
+            timeoutRef.current = setTimeout(() => {
+                setIsPolling(false);
+                startTimeRef.current = null;
+                clearStoredState(queueName);
+                onCompleteRef.current();
+            }, remainingTime);
+        } else if (stored) {
+            clearStoredState(queueName);
+        }
+    }, [queueName]);
+
+    // Calculate exponential backoff interval
+    const pollInterval = isPolling
+        ? Math.min(INITIAL_POLL_INTERVAL_MS * Math.pow(1.75, pollCount), MAX_POLL_INTERVAL_MS)
+        : false;
+
+    const { data: jobsData } = useQuery({
+        queryKey: ['jobQueuePolling', queueName],
+        queryFn: () => {
+            setPollCount(c => c + 1);
+            return api.query(jobListForPollingDocument, {
+                options: {
+                    filter: { queueName: { eq: queueName } },
+                    sort: { createdAt: 'DESC' as const },
+                    take: 10,
+                },
+            });
+        },
+        enabled: isPolling,
+        refetchInterval: pollInterval,
+    });
+
+    // Detect job completion
+    useEffect(() => {
+        const startTime = startTimeRef.current;
+        if (!isPolling || !startTime) return;
+
+        const relevantJobs = jobsData?.jobs.items.filter(j => j.createdAt >= startTime) ?? [];
+        const hasSettledJob =
+            relevantJobs.length > 0 &&
+            relevantJobs.every(j => j.state !== 'PENDING' && j.state !== 'RUNNING' && j.state !== 'RETRYING');
+
+        if (hasSettledJob) {
+            setIsPolling(false);
+            startTimeRef.current = null;
+            clearStoredState(queueName);
+            if (timeoutRef.current) {
+                clearTimeout(timeoutRef.current);
+                timeoutRef.current = null;
+            }
+            onCompleteRef.current();
+        }
+    }, [jobsData, isPolling, queueName]);
+
+    useEffect(() => {
+        return () => {
+            if (timeoutRef.current) clearTimeout(timeoutRef.current);
+        };
+    }, []);
+
+    const startPolling = useCallback(() => {
+        if (timeoutRef.current) clearTimeout(timeoutRef.current);
+
+        const startTime = new Date(Date.now() - JOB_LOOKBACK_MS).toISOString();
+        const expiresAt = Date.now() + MAX_POLLING_TIMEOUT_MS;
+
+        // Store in sessionStorage so polling can resume after navigation
+        setStoredState(queueName, { startTime, expiresAt });
+
+        startTimeRef.current = startTime;
+        setPollCount(0);
+        setIsPolling(true);
+
+        timeoutRef.current = setTimeout(() => {
+            setIsPolling(false);
+            startTimeRef.current = null;
+            clearStoredState(queueName);
+            onCompleteRef.current();
+        }, MAX_POLLING_TIMEOUT_MS);
+    }, [queueName]);
+
+    return { isPolling, startPolling };
+}