Files
igny8/frontend/src/hooks/usePersistentToggle.ts

236 lines
6.1 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { fetchAPI } from '../services/api';
interface UsePersistentToggleOptions {
/**
* Unique identifier for the resource (e.g., 'openai', 'runware', 'gsc')
*/
resourceId: string;
/**
* API endpoint pattern - will replace {id} with resourceId
* Example: '/v1/system/settings/integrations/{id}/'
*/
getEndpoint: string;
/**
* API endpoint pattern for saving - will replace {id} with resourceId
* Example: '/v1/system/settings/integrations/{id}/save/'
*/
saveEndpoint: string;
/**
* Initial enabled state (used before data loads)
*/
initialEnabled?: boolean;
/**
* Function to extract enabled state from API response
* Default: (data) => data?.enabled ?? false
*/
extractEnabled?: (data: any) => boolean;
/**
* Function to build save payload
* Default: (currentData, enabled) => ({ ...currentData, enabled })
*/
buildPayload?: (currentData: any, enabled: boolean) => any;
/**
* Callback when toggle succeeds
* @param enabled - The new enabled state
* @param data - The full config data from the API
*/
onToggleSuccess?: (enabled: boolean, data?: any) => void;
/**
* Callback when toggle fails
*/
onToggleError?: (error: Error) => void;
/**
* Whether to load state on mount
*/
loadOnMount?: boolean;
}
interface UsePersistentToggleReturn {
/**
* Current enabled state
*/
enabled: boolean;
/**
* Toggle function - automatically persists to backend
*/
toggle: (enabled: boolean) => Promise<void>;
/**
* Loading state (during save/load operations)
*/
loading: boolean;
/**
* Error state
*/
error: Error | null;
/**
* Full config data from API
*/
data: any;
/**
* Manually refresh state from API
*/
refresh: () => Promise<void>;
}
/**
* Hook for managing persistent toggle state with automatic API synchronization
*
* Features:
* - Automatically loads state on mount
* - Automatically saves state on toggle
* - Handles loading and error states
* - Can be used for any persistent boolean state
*
* @example
* ```tsx
* const { enabled, toggle, loading } = usePersistentToggle({
* resourceId: 'openai',
* getEndpoint: '/v1/system/settings/integrations/{id}/',
* saveEndpoint: '/v1/system/settings/integrations/{id}/save/',
* onToggleSuccess: (enabled) => toast.success(`Integration ${enabled ? 'enabled' : 'disabled'}`),
* });
* ```
*/
export function usePersistentToggle(
options: UsePersistentToggleOptions
): UsePersistentToggleReturn {
const {
resourceId,
getEndpoint,
saveEndpoint,
initialEnabled = false,
extractEnabled = (data: any) => data?.enabled ?? false,
buildPayload = (currentData: any, enabled: boolean) => ({ ...currentData, enabled }),
onToggleSuccess,
onToggleError,
loadOnMount = true,
} = options;
const [enabled, setEnabled] = useState(initialEnabled);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [data, setData] = useState<any>(null);
/**
* Load state from API
*/
const loadState = useCallback(async () => {
setLoading(true);
setError(null);
try {
const endpoint = getEndpoint.replace('{id}', resourceId);
// fetchAPI extracts data from unified format {success: true, data: {...}}
// So result IS the data object, not wrapped
const result = await fetchAPI(endpoint);
if (result && typeof result === 'object') {
setData(result);
const newEnabled = extractEnabled(result);
setEnabled(newEnabled);
} else {
// No data yet - use initial state
setData({});
setEnabled(initialEnabled);
}
} catch (err: any) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
console.error(`Error loading state for ${resourceId}:`, error);
// Don't throw - just log and keep initial state
} finally {
setLoading(false);
}
}, [resourceId, getEndpoint, extractEnabled, initialEnabled]);
/**
* Save state to API
*/
const saveState = useCallback(async (newEnabled: boolean) => {
setLoading(true);
setError(null);
try {
const endpoint = saveEndpoint.replace('{id}', resourceId);
const payload = buildPayload(data || {}, newEnabled);
// fetchAPI extracts data from unified format {success: true, data: {...}}
// If no error is thrown, assume success
await fetchAPI(endpoint, {
method: 'POST',
body: JSON.stringify(payload),
});
// Update local state
const updatedData = { ...(data || {}), enabled: newEnabled };
setData(updatedData);
setEnabled(newEnabled);
// Call success callback - pass both enabled state and full config data
if (onToggleSuccess) {
onToggleSuccess(newEnabled, updatedData);
}
} catch (err: any) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
console.error(`Error saving state for ${resourceId}:`, error);
// Call error callback
if (onToggleError) {
onToggleError(error);
}
// Revert to previous state on error
// Don't throw - let component handle error display
} finally {
setLoading(false);
}
}, [resourceId, saveEndpoint, buildPayload, data, onToggleSuccess, onToggleError]);
/**
* Toggle function - automatically persists
*/
const toggle = useCallback(async (newEnabled: boolean) => {
await saveState(newEnabled);
}, [saveState]);
/**
* Refresh state from API
*/
const refresh = useCallback(async () => {
await loadState();
}, [loadState]);
// Load state on mount - only once, don't re-run when dependencies change
useEffect(() => {
if (loadOnMount) {
loadState();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadOnMount]); // Only depend on loadOnMount, not loadState
return {
enabled,
toggle,
loading,
error,
data,
refresh,
};
}