From d31f888e75c2dd771e06ecb0d7ae82734a9ed147 Mon Sep 17 00:00:00 2001 From: aliraza556 Date: Tue, 7 Jan 2025 12:35:33 +0500 Subject: [PATCH 1/2] feat(feature-flags): implement feature flag hook and workspace planner toggle --- src/hooks/__tests__/useFeatureFlag.spec.tsx | 91 +++++++++++++++++++ src/hooks/useFeatureFlag.ts | 79 ++++++++++++++++ src/people/widgetViews/WorkspaceMission.tsx | 6 +- .../__tests__/WorkspaceMission.spec.tsx | 86 ++++++++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/hooks/__tests__/useFeatureFlag.spec.tsx create mode 100644 src/hooks/useFeatureFlag.ts create mode 100644 src/people/widgetViews/__tests__/WorkspaceMission.spec.tsx diff --git a/src/hooks/__tests__/useFeatureFlag.spec.tsx b/src/hooks/__tests__/useFeatureFlag.spec.tsx new file mode 100644 index 00000000..eab7fa9f --- /dev/null +++ b/src/hooks/__tests__/useFeatureFlag.spec.tsx @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; +import { useFeatureFlag } from '../useFeatureFlag'; +import { mainStore } from '../../store/main'; + +jest.mock('../../store/main', () => ({ + mainStore: { + getFeatureFlags: jest.fn() + } +})); + +describe('useFeatureFlag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return loading state initially', () => { + const { result } = renderHook(() => useFeatureFlag('display_planner')); + expect(result.current.loading).toBe(true); + expect(result.current.isEnabled).toBe(false); + }); + + it('should return enabled state when flag is enabled', async () => { + (mainStore.getFeatureFlags as jest.Mock).mockResolvedValueOnce({ + success: true, + data: [ + { + name: 'display_planner', + enabled: true, + uuid: 'test-uuid', + description: 'test', + endpoints: [] + } + ] + }); + + const { result, waitForNextUpdate } = renderHook(() => useFeatureFlag('display_planner')); + + await waitForNextUpdate(); + + expect(result.current.loading).toBe(false); + expect(result.current.isEnabled).toBe(true); + }); + + it('should return disabled state when flag is disabled', async () => { + (mainStore.getFeatureFlags as jest.Mock).mockResolvedValueOnce({ + success: true, + data: [ + { + name: 'display_planner', + enabled: false, + uuid: 'test-uuid', + description: 'test', + endpoints: [] + } + ] + }); + + const { result, waitForNextUpdate } = renderHook(() => useFeatureFlag('display_planner')); + + waitFor(() => { + waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.isEnabled).toBe(false); + }); + }); + + it('should handle errors', async () => { + (mainStore.getFeatureFlags as jest.Mock).mockRejectedValueOnce(new Error('API Error')); + + const { result, waitForNextUpdate } = renderHook(() => useFeatureFlag('display_planner')); + + waitFor(() => { + waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeDefined(); + expect(result.current.isEnabled).toBe(false); + }); + }); + + it('should refresh flags when refresh is called', async () => { + const { result, waitForNextUpdate } = renderHook(() => useFeatureFlag('display_planner')); + + await waitForNextUpdate(); + + waitFor(() => { + result.current.refresh(); + expect(mainStore.getFeatureFlags).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/hooks/useFeatureFlag.ts b/src/hooks/useFeatureFlag.ts new file mode 100644 index 00000000..74fac25e --- /dev/null +++ b/src/hooks/useFeatureFlag.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, useCallback } from 'react'; +import { mainStore } from '../store/main'; +import { FeatureFlag } from '../store/interface'; + +interface FlagCache { + flags: FeatureFlag[] | null; + lastFetched: number | null; + expiryTime: number; +} + +const flagCache: FlagCache = { + flags: null, + lastFetched: null, + expiryTime: 5 * 60 * 1000 +}; + +interface UseFeatureFlagResult { + isEnabled: boolean; + loading: boolean; + error?: Error; + refresh: () => Promise; +} + +export const useFeatureFlag = (flagName: string): UseFeatureFlagResult => { + const [isEnabled, setIsEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(undefined); + + const isCacheValid = () => + !!( + flagCache.flags && + flagCache.lastFetched && + Date.now() - flagCache.lastFetched < flagCache.expiryTime + ); + + const fetchFlags = useCallback(async () => { + try { + setLoading(true); + const response = await mainStore.getFeatureFlags(); + + if (response?.success) { + flagCache.flags = response.data; + flagCache.lastFetched = Date.now(); + + const flag = response.data.find((f: FeatureFlag) => f.name === flagName); + setIsEnabled(flag?.enabled || false); + } else { + throw new Error('Failed to fetch feature flags'); + } + } catch (e) { + setError(e instanceof Error ? e : new Error('Unknown error occurred')); + console.error('Error fetching feature flags:', e); + } finally { + setLoading(false); + } + }, [flagName]); + + const refresh = async () => { + flagCache.flags = null; + flagCache.lastFetched = null; + await fetchFlags(); + }; + + useEffect(() => { + const getFlags = async () => { + if (isCacheValid() && flagCache.flags) { + const flag = flagCache.flags.find((f: FeatureFlag) => f.name === flagName); + setIsEnabled(flag?.enabled || false); + setLoading(false); + } else { + await fetchFlags(); + } + }; + + getFlags(); + }, [flagName, fetchFlags]); + + return { isEnabled, loading, error, refresh }; +}; diff --git a/src/people/widgetViews/WorkspaceMission.tsx b/src/people/widgetViews/WorkspaceMission.tsx index 08d84355..9d5fd311 100644 --- a/src/people/widgetViews/WorkspaceMission.tsx +++ b/src/people/widgetViews/WorkspaceMission.tsx @@ -65,6 +65,7 @@ import { SchematicPreview } from 'people/SchematicPreviewer'; import avatarIcon from '../../public/static/profile_avatar.svg'; import { colors } from '../../config/colors'; import dragIcon from '../../pages/superadmin/header/icons/drag_indicator.svg'; +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import AddCodeGraph from './workspace/AddCodeGraphModal'; import AddFeature from './workspace/AddFeatureModal'; import { @@ -323,6 +324,9 @@ const WorkspaceMission = () => { const [toasts, setToasts] = useState([]); const [holding, setHolding] = useState(false); + const { isEnabled: isPlannerEnabled, loading: isPlannerLoading } = + useFeatureFlag('display_planner'); + const fetchCodeGraph = useCallback(async () => { try { const data = await main.getWorkspaceCodeGraph(uuid); @@ -1145,7 +1149,7 @@ const WorkspaceMission = () => { - {uuid && ( + {uuid && isPlannerEnabled && !isPlannerLoading && (