236 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
|