Unverified 提交 04087298 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Refactor useSupabase hook to be idiomatic (#2017)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Moves Supabase data fetching/mutations to React Query and aligns UI with new query states for clearer loading/errors and cache-driven updates. > > - Removed most Supabase atoms; kept `lastLogTimestampAtom` only > - New `useSupabase` exposes React Query queries (`organizations`, `projects`, `branches`) and mutations (delete org, set/unset app project, edge logs) with invalidate/refetch helpers > - `SupabaseConnector` and `SupabaseIntegration` now use `refetch*`, granular `isLoading*/error` flags, and updated handlers; branch select disabled via `isLoadingBranches`/`isSettingAppProject` > - `PreviewPanel` switches `loadEdgeLogs` to accept `{ projectId, organizationSlug }` and continues polling > - OAuth return flow now calls `refetchOrganizations`/`refetchProjects` instead of manual load functions > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 573df5298f323854d4a8aa1ce5903b99e4caba62. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Refactored Supabase integration to use TanStack React Query for data fetching and mutations. This makes loading/error handling clearer, improves cache invalidation, and smooths the UI. - **Refactors** - Replaced Jotai state with React Query for organizations, projects, and branches; removed related atoms. - Added mutations for delete organization, set/unset app project, and edge logs; invalidates org/project queries on deletion. - Exposed granular states for organizations, projects, and branches; removed selected project state. - Updated SupabaseConnector/SupabaseIntegration to use refetch* methods and new flags; PreviewPanel now calls loadEdgeLogs with params; disables branch select while loading or setting. <sup>Written for commit 573df5298f323854d4a8aa1ce5903b99e4caba62. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
上级 de798def
import { atom } from "jotai"; import { atom } from "jotai";
import {
SupabaseBranch,
SupabaseOrganizationInfo,
SupabaseProject,
} from "@/ipc/ipc_types";
// Define atom for storing the list of connected Supabase organizations
export const supabaseOrganizationsAtom = atom<SupabaseOrganizationInfo[]>([]);
// Define atom for storing the list of Supabase projects
export const supabaseProjectsAtom = atom<SupabaseProject[]>([]);
export const supabaseBranchesAtom = atom<SupabaseBranch[]>([]);
// Define atom for tracking loading state
export const supabaseLoadingAtom = atom<boolean>(false);
// Define atom for storing any error that occurs during loading
export const supabaseErrorAtom = atom<Error | null>(null);
// Define atom for storing the currently selected Supabase project
export const selectedSupabaseProjectAtom = atom<string | null>(null);
// Define atom for tracking the last log timestamp per project (for incremental log loading) // Define atom for tracking the last log timestamp per project (for incremental log loading)
export const lastLogTimestampAtom = atom<Record<string, number>>({}); export const lastLogTimestampAtom = atom<Record<string, number>>({});
...@@ -47,46 +47,44 @@ export function SupabaseConnector({ appId }: { appId: number }) { ...@@ -47,46 +47,44 @@ export function SupabaseConnector({ appId }: { appId: number }) {
const { app, refreshApp } = useLoadApp(appId); const { app, refreshApp } = useLoadApp(appId);
const { lastDeepLink, clearLastDeepLink } = useDeepLink(); const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
// Check if there are any connected organizations
const isConnected = isSupabaseConnected(settings);
const branchesProjectId =
app?.supabaseParentProjectId || app?.supabaseProjectId;
const {
organizations,
projects,
branches,
isLoadingProjects,
isFetchingProjects,
projectsError,
isLoadingBranches,
isSettingAppProject,
refetchOrganizations,
refetchProjects,
deleteOrganization,
setAppProject,
unsetAppProject,
} = useSupabase({
branchesProjectId,
branchesOrganizationSlug: app?.supabaseOrganizationSlug,
});
useEffect(() => { useEffect(() => {
const handleDeepLink = async () => { const handleDeepLink = async () => {
if (lastDeepLink?.type === "supabase-oauth-return") { if (lastDeepLink?.type === "supabase-oauth-return") {
await refreshSettings(); await refreshSettings();
await loadOrganizations(); await refetchOrganizations();
await loadProjects(); await refetchProjects();
await refreshApp(); await refreshApp();
clearLastDeepLink(); clearLastDeepLink();
} }
}; };
handleDeepLink(); handleDeepLink();
}, [lastDeepLink?.timestamp]); }, [lastDeepLink?.timestamp]);
const {
organizations,
projects,
loading,
error,
loadOrganizations,
deleteOrganization,
loadProjects,
branches,
loadBranches,
setAppProject,
unsetAppProject,
} = useSupabase();
// Check if there are any connected organizations
const isConnected = isSupabaseConnected(settings);
useEffect(() => {
// Load organizations and projects when the component mounts
loadOrganizations();
}, [loadOrganizations]);
useEffect(() => {
// Load projects when organizations are available
if (isConnected) {
loadProjects();
}
}, [isConnected, loadProjects]);
const handleProjectSelect = async (projectValue: string) => { const handleProjectSelect = async (projectValue: string) => {
try { try {
...@@ -145,17 +143,6 @@ export function SupabaseConnector({ appId }: { appId: number }) { ...@@ -145,17 +143,6 @@ export function SupabaseConnector({ appId }: { appId: number }) {
} }
}; };
const projectIdForBranches =
app?.supabaseParentProjectId || app?.supabaseProjectId;
useEffect(() => {
if (projectIdForBranches) {
loadBranches(
projectIdForBranches,
app?.supabaseOrganizationSlug ?? undefined,
);
}
}, [projectIdForBranches, loadBranches, app?.supabaseOrganizationSlug]);
const handleUnsetProject = async () => { const handleUnsetProject = async () => {
try { try {
await unsetAppProject(appId); await unsetAppProject(appId);
...@@ -171,7 +158,6 @@ export function SupabaseConnector({ appId }: { appId: number }) { ...@@ -171,7 +158,6 @@ export function SupabaseConnector({ appId }: { appId: number }) {
try { try {
await deleteOrganization({ organizationSlug }); await deleteOrganization({ organizationSlug });
toast.success("Organization disconnected successfully"); toast.success("Organization disconnected successfully");
await loadProjects();
} catch (error) { } catch (error) {
toast.error("Failed to disconnect organization: " + error); toast.error("Failed to disconnect organization: " + error);
} }
...@@ -242,7 +228,7 @@ export function SupabaseConnector({ appId }: { appId: number }) { ...@@ -242,7 +228,7 @@ export function SupabaseConnector({ appId }: { appId: number }) {
toast.error("Failed to set branch: " + error); toast.error("Failed to set branch: " + error);
} }
}} }}
disabled={loading} disabled={isLoadingBranches || isSettingAppProject}
> >
<SelectTrigger <SelectTrigger
id="supabase-branch-select" id="supabase-branch-select"
...@@ -301,18 +287,18 @@ export function SupabaseConnector({ appId }: { appId: number }) { ...@@ -301,18 +287,18 @@ export function SupabaseConnector({ appId }: { appId: number }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading ? ( {isLoadingProjects || isFetchingProjects ? (
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
</div> </div>
) : error ? ( ) : projectsError ? (
<div className="text-red-500"> <div className="text-red-500">
Error loading projects: {error.message} Error loading projects: {projectsError.message}
<Button <Button
variant="outline" variant="outline"
className="mt-2" className="mt-2"
onClick={() => loadProjects()} onClick={() => refetchProjects()}
> >
Retry Retry
</Button> </Button>
......
import { useState, useEffect } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -12,13 +12,13 @@ import { isSupabaseConnected } from "@/lib/schemas"; ...@@ -12,13 +12,13 @@ import { isSupabaseConnected } from "@/lib/schemas";
export function SupabaseIntegration() { export function SupabaseIntegration() {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { organizations, loadOrganizations, deleteOrganization } =
useSupabase();
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
useEffect(() => { // Check if there are any connected organizations
loadOrganizations(); const isConnected = isSupabaseConnected(settings);
}, [loadOrganizations]);
const { organizations, refetchOrganizations, deleteOrganization } =
useSupabase();
const handleDisconnectAllFromSupabase = async () => { const handleDisconnectAllFromSupabase = async () => {
setIsDisconnecting(true); setIsDisconnecting(true);
...@@ -31,7 +31,7 @@ export function SupabaseIntegration() { ...@@ -31,7 +31,7 @@ export function SupabaseIntegration() {
}); });
if (result) { if (result) {
showSuccess("Successfully disconnected all Supabase organizations"); showSuccess("Successfully disconnected all Supabase organizations");
await loadOrganizations(); await refetchOrganizations();
} else { } else {
showError("Failed to disconnect from Supabase"); showError("Failed to disconnect from Supabase");
} }
...@@ -64,9 +64,6 @@ export function SupabaseIntegration() { ...@@ -64,9 +64,6 @@ export function SupabaseIntegration() {
} }
}; };
// Check if there are any connected organizations
const isConnected = isSupabaseConnected(settings);
if (!isConnected) { if (!isConnected) {
return null; return null;
} }
......
...@@ -117,13 +117,13 @@ export function PreviewPanel() { ...@@ -117,13 +117,13 @@ export function PreviewPanel() {
if (!projectId) return; if (!projectId) return;
// Load logs immediately // Load logs immediately
loadEdgeLogs(projectId, organizationSlug).catch((error) => { loadEdgeLogs({ projectId, organizationSlug }).catch((error) => {
console.error("Failed to load edge logs:", error); console.error("Failed to load edge logs:", error);
}); });
// Poll for new logs every 5 seconds // Poll for new logs every 5 seconds
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
loadEdgeLogs(projectId, organizationSlug).catch((error) => { loadEdgeLogs({ projectId, organizationSlug }).catch((error) => {
console.error("Failed to load edge logs:", error); console.error("Failed to load edge logs:", error);
}); });
}, 5000); }, 5000);
......
import { useCallback } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import { lastLogTimestampAtom } from "@/atoms/supabaseAtoms";
supabaseOrganizationsAtom,
supabaseProjectsAtom,
supabaseBranchesAtom,
supabaseLoadingAtom,
supabaseErrorAtom,
selectedSupabaseProjectAtom,
lastLogTimestampAtom,
} from "@/atoms/supabaseAtoms";
import { appConsoleEntriesAtom, selectedAppIdAtom } from "@/atoms/appAtoms"; import { appConsoleEntriesAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { import {
SetSupabaseAppProjectParams, SetSupabaseAppProjectParams,
DeleteSupabaseOrganizationParams, DeleteSupabaseOrganizationParams,
SupabaseOrganizationInfo,
SupabaseProject,
SupabaseBranch,
} from "@/ipc/ipc_types"; } from "@/ipc/ipc_types";
import { useSettings } from "./useSettings";
import { isSupabaseConnected } from "@/lib/schemas";
const SUPABASE_QUERY_KEYS = {
organizations: ["supabase", "organizations"] as const,
projects: ["supabase", "projects"] as const,
branches: (projectId: string) => ["supabase", "branches", projectId] as const,
};
export interface UseSupabaseOptions {
branchesProjectId?: string | null;
branchesOrganizationSlug?: string | null;
}
export function useSupabase(options: UseSupabaseOptions = {}) {
const { branchesProjectId, branchesOrganizationSlug } = options;
const queryClient = useQueryClient();
const { settings } = useSettings();
const isConnected = isSupabaseConnected(settings);
export function useSupabase() {
const [organizations, setOrganizations] = useAtom(supabaseOrganizationsAtom);
const [projects, setProjects] = useAtom(supabaseProjectsAtom);
const [branches, setBranches] = useAtom(supabaseBranchesAtom);
const [loading, setLoading] = useAtom(supabaseLoadingAtom);
const [error, setError] = useAtom(supabaseErrorAtom);
const [selectedProject, setSelectedProject] = useAtom(
selectedSupabaseProjectAtom,
);
const setConsoleEntries = useSetAtom(appConsoleEntriesAtom); const setConsoleEntries = useSetAtom(appConsoleEntriesAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
const [lastLogTimestamp, setLastLogTimestamp] = useAtom(lastLogTimestampAtom); const [lastLogTimestamp, setLastLogTimestamp] = useAtom(lastLogTimestampAtom);
// Query: Load all connected Supabase organizations
// Only runs when Supabase is connected to avoid unnecessary API calls
const organizationsQuery = useQuery<SupabaseOrganizationInfo[], Error>({
queryKey: SUPABASE_QUERY_KEYS.organizations,
queryFn: async () => {
const ipcClient = IpcClient.getInstance(); const ipcClient = IpcClient.getInstance();
return ipcClient.listSupabaseOrganizations();
},
enabled: isConnected,
meta: { showErrorToast: true },
});
/** // Query: Load Supabase projects from all connected organizations
* Load all connected Supabase organizations // Only runs when there are connected organizations to avoid unauthorized errors
*/ const projectsQuery = useQuery<SupabaseProject[], Error>({
const loadOrganizations = useCallback(async () => { queryKey: SUPABASE_QUERY_KEYS.projects,
setLoading(true); queryFn: async () => {
try { const ipcClient = IpcClient.getInstance();
const orgList = await ipcClient.listSupabaseOrganizations(); return ipcClient.listAllSupabaseProjects();
setOrganizations(orgList);
setError(null);
} catch (error) {
console.error("Error loading Supabase organizations:", error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
}, [ipcClient, setOrganizations, setError, setLoading]);
/**
* Delete a Supabase organization connection
*/
const deleteOrganization = useCallback(
async (params: DeleteSupabaseOrganizationParams) => {
setLoading(true);
try {
await ipcClient.deleteSupabaseOrganization(params);
// Refresh organizations list after deletion
const orgList = await ipcClient.listSupabaseOrganizations();
setOrganizations(orgList);
setError(null);
} catch (error) {
console.error("Error deleting Supabase organization:", error);
setError(error instanceof Error ? error : new Error(String(error)));
throw error;
} finally {
setLoading(false);
}
}, },
[ipcClient, setOrganizations, setError, setLoading], enabled: (organizationsQuery.data?.length ?? 0) > 0,
); meta: { showErrorToast: true },
});
/** // Mutation: Delete a Supabase organization connection
* Load Supabase projects from all connected organizations const deleteOrganizationMutation = useMutation<
*/ void,
const loadProjects = useCallback(async () => { Error,
setLoading(true); DeleteSupabaseOrganizationParams
try { >({
const projectList = await ipcClient.listAllSupabaseProjects(); mutationFn: async (params) => {
setProjects(projectList); const ipcClient = IpcClient.getInstance();
setError(null); await ipcClient.deleteSupabaseOrganization(params);
} catch (error) { },
console.error("Error loading Supabase projects:", error); onSuccess: () => {
setError(error instanceof Error ? error : new Error(String(error))); queryClient.invalidateQueries({
} finally { queryKey: SUPABASE_QUERY_KEYS.organizations,
setLoading(false);
}
}, [ipcClient, setProjects, setError, setLoading]);
/**
* Load branches for a Supabase project
*/
const loadBranches = useCallback(
async (projectId: string, organizationSlug?: string) => {
setLoading(true);
try {
const list = await ipcClient.listSupabaseBranches({
projectId,
organizationSlug: organizationSlug ?? null,
}); });
setBranches(Array.isArray(list) ? list : []); queryClient.invalidateQueries({ queryKey: SUPABASE_QUERY_KEYS.projects });
setError(null);
} catch (error) {
console.error("Error loading Supabase branches:", error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
}, },
[ipcClient, setBranches, setError, setLoading], meta: { showErrorToast: true },
); });
/** // Mutation: Associate a Supabase project with an app
* Associate a Supabase project with an app const setAppProjectMutation = useMutation<
*/ void,
const setAppProject = useCallback( Error,
async (params: SetSupabaseAppProjectParams) => { SetSupabaseAppProjectParams
setLoading(true); >({
try { mutationFn: async (params) => {
const ipcClient = IpcClient.getInstance();
await ipcClient.setSupabaseAppProject(params); await ipcClient.setSupabaseAppProject(params);
setError(null);
} catch (error) {
console.error("Error setting Supabase project for app:", error);
setError(error instanceof Error ? error : new Error(String(error)));
throw error;
} finally {
setLoading(false);
}
}, },
[ipcClient, setError, setLoading], meta: { showErrorToast: true },
); });
/** // Mutation: Remove a Supabase project association from an app
* Remove a Supabase project association from an app const unsetAppProjectMutation = useMutation<void, Error, number>({
*/ mutationFn: async (appId) => {
const unsetAppProject = useCallback( const ipcClient = IpcClient.getInstance();
async (appId: number) => {
setLoading(true);
try {
await ipcClient.unsetSupabaseAppProject(appId); await ipcClient.unsetSupabaseAppProject(appId);
setError(null);
} catch (error) {
console.error("Error unsetting Supabase project for app:", error);
setError(error instanceof Error ? error : new Error(String(error)));
throw error;
} finally {
setLoading(false);
}
}, },
[ipcClient, setError, setLoading], meta: { showErrorToast: true },
); });
/** // Query: Load branches for a Supabase project
* Load edge function logs for a Supabase project const branchesQuery = useQuery<SupabaseBranch[], Error>({
* Uses timestamp tracking to only fetch new logs on subsequent calls queryKey: [
*/ ...SUPABASE_QUERY_KEYS.branches(branchesProjectId ?? ""),
const loadEdgeLogs = useCallback( branchesOrganizationSlug ?? null,
async (projectId: string, organizationSlug?: string) => { ],
queryFn: async () => {
const ipcClient = IpcClient.getInstance();
const list = await ipcClient.listSupabaseBranches({
projectId: branchesProjectId!,
organizationSlug: branchesOrganizationSlug ?? null,
});
return Array.isArray(list) ? list : [];
},
enabled: !!branchesProjectId,
meta: { showErrorToast: true },
});
// Mutation: Load edge function logs for a Supabase project
// Using mutation because it has side effects (updating console entries)
const loadEdgeLogsMutation = useMutation<
void,
Error,
{ projectId: string; organizationSlug?: string }
>({
mutationFn: async ({ projectId, organizationSlug }) => {
if (!selectedAppId) return; if (!selectedAppId) return;
const ipcClient = IpcClient.getInstance();
// Use last timestamp if available, otherwise fetch logs from the past 10 minutes // Use last timestamp if available, otherwise fetch logs from the past 10 minutes
const lastTimestamp = lastLogTimestamp[projectId]; const lastTimestamp = lastLogTimestamp[projectId];
const timestampStart = lastTimestamp ?? Date.now() - 10 * 60 * 1000; // 10 minutes ago const timestampStart = lastTimestamp ?? Date.now() - 10 * 60 * 1000;
setLoading(true);
try {
// Fetch logs - handler returns ConsoleEntry[] already formatted
const logs = await ipcClient.getSupabaseEdgeLogs({ const logs = await ipcClient.getSupabaseEdgeLogs({
projectId, projectId,
timestampStart, timestampStart,
...@@ -181,7 +148,6 @@ export function useSupabase() { ...@@ -181,7 +148,6 @@ export function useSupabase() {
[projectId]: Date.now(), [projectId]: Date.now(),
})); }));
} }
setError(null);
return; return;
} }
...@@ -196,50 +162,43 @@ export function useSupabase() { ...@@ -196,50 +162,43 @@ export function useSupabase() {
...prev, ...prev,
[projectId]: latestLog.timestamp, [projectId]: latestLog.timestamp,
})); }));
setError(null);
} catch (error) {
console.error("Error loading Supabase edge logs:", error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
}, },
[ });
ipcClient,
setConsoleEntries,
setError,
setLoading,
selectedAppId,
lastLogTimestamp,
setLastLogTimestamp,
],
);
/**
* Select a project for current use
*/
const selectProject = useCallback(
(projectId: string | null) => {
setSelectedProject(projectId);
},
[setSelectedProject],
);
return { return {
organizations, // Data
projects, organizations: organizationsQuery.data ?? [],
branches, projects: projectsQuery.data ?? [],
loading, branches: branchesQuery.data ?? [],
error,
selectedProject, // Organizations query state
loadOrganizations, isLoadingOrganizations: organizationsQuery.isLoading,
deleteOrganization, isFetchingOrganizations: organizationsQuery.isFetching,
loadProjects, organizationsError: organizationsQuery.error,
loadBranches,
loadEdgeLogs, // Projects query state
setAppProject, isLoadingProjects: projectsQuery.isLoading,
unsetAppProject, isFetchingProjects: projectsQuery.isFetching,
selectProject, projectsError: projectsQuery.error,
// Branches query state
isLoadingBranches: branchesQuery.isLoading,
isFetchingBranches: branchesQuery.isFetching,
branchesError: branchesQuery.error,
// Mutation states
isDeletingOrganization: deleteOrganizationMutation.isPending,
isSettingAppProject: setAppProjectMutation.isPending,
isUnsettingAppProject: unsetAppProjectMutation.isPending,
isLoadingEdgeLogs: loadEdgeLogsMutation.isPending,
// Actions
refetchOrganizations: organizationsQuery.refetch,
refetchProjects: projectsQuery.refetch,
refetchBranches: branchesQuery.refetch,
deleteOrganization: deleteOrganizationMutation.mutateAsync,
loadEdgeLogs: loadEdgeLogsMutation.mutateAsync,
setAppProject: setAppProjectMutation.mutateAsync,
unsetAppProject: unsetAppProjectMutation.mutateAsync,
}; };
} }
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论