Skip to content

Commit

Permalink
Merge pull request stakwork#877 from aliraza556/implement-feature-fla…
Browse files Browse the repository at this point in the history
…g-hook

 Add Feature Flag System with Workspace Planner Integration
  • Loading branch information
humansinstitute authored Jan 7, 2025
2 parents bb5263c + 0562d59 commit 28db235
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 1 deletion.
91 changes: 91 additions & 0 deletions src/hooks/__tests__/useFeatureFlag.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 the flags when refresh is called', async () => {
const { result, waitForNextUpdate } = renderHook(() => useFeatureFlag('display_planner'));

await waitForNextUpdate();

waitFor(() => {
result.current.refresh();
expect(mainStore.getFeatureFlags).toHaveBeenCalledTimes(2);
});
});
});
79 changes: 79 additions & 0 deletions src/hooks/useFeatureFlag.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

export const useFeatureFlag = (flagName: string): UseFeatureFlagResult => {
const [isEnabled, setIsEnabled] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | undefined>(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 };
};
6 changes: 5 additions & 1 deletion src/people/widgetViews/WorkspaceMission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -323,6 +324,9 @@ const WorkspaceMission = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
const [holding, setHolding] = useState(false);

const { isEnabled: isPlannerEnabled, loading: isPlannerLoading } =
useFeatureFlag('display_planner');

const fetchCodeGraph = useCallback(async () => {
try {
const data = await main.getWorkspaceCodeGraph(uuid);
Expand Down Expand Up @@ -1145,7 +1149,7 @@ const WorkspaceMission = () => {
</FieldWrap>

<HorizontalGrayLine />
{uuid && (
{uuid && isPlannerEnabled && !isPlannerLoading && (
<WorkspaceFieldWrap>
<Button
style={{
Expand Down
86 changes: 86 additions & 0 deletions src/people/widgetViews/__tests__/WorkspaceMission.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route } from 'react-router-dom';
import WorkspaceMission from '../WorkspaceMission';
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
import { withProviders } from '../../../providers';

jest.mock('../../../hooks/useFeatureFlag');

const mockCrypto = {
getRandomValues: function (buffer: Uint8Array): Uint8Array {
for (let i = 0; i < buffer.length; i++) {
buffer[i] = Math.floor(Math.random() * 256);
}
return buffer;
}
};
global.crypto = mockCrypto as Crypto;

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
children
}: {
children: React.ReactNode;
}) => {
const WrappedComponent = withProviders(() => (
<MemoryRouter initialEntries={['/workspace/test-uuid']}>
<Route path="/workspace/:uuid">{children}</Route>
</MemoryRouter>
));

return <WrappedComponent />;
};

describe('WorkspaceMission', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should show planner button when feature flag is enabled', async () => {
(useFeatureFlag as jest.Mock).mockReturnValue({
isEnabled: true,
loading: false
});

render(
<TestWrapper>
<WorkspaceMission />
</TestWrapper>
);
waitFor(() => {
expect(screen.getByTestId('workspace-planner-btn')).toBeInTheDocument();
});
});

it('should hide planner button when feature flag is disabled', async () => {
(useFeatureFlag as jest.Mock).mockReturnValue({
isEnabled: false,
loading: false
});

render(
<TestWrapper>
<WorkspaceMission />
</TestWrapper>
);
waitFor(() => {
expect(screen.queryByTestId('workspace-planner-btn')).not.toBeInTheDocument();
});
});

it('should hide planner button while loading', async () => {
(useFeatureFlag as jest.Mock).mockReturnValue({
isEnabled: true,
loading: true
});

render(
<TestWrapper>
<WorkspaceMission />
</TestWrapper>
);
waitFor(() => {
expect(screen.queryByTestId('workspace-planner-btn')).not.toBeInTheDocument();
});
});
});

0 comments on commit 28db235

Please sign in to comment.