diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx index aa272c62bca1..cc0c136bf222 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx @@ -29,7 +29,7 @@ interface FeatureFlagRowProps { onToggle: (key: string, newValue: unknown) => void; } -interface MinimumVersionFlagValue { +export interface MinimumVersionFlagValue { enabled: boolean; minimumVersion: string; } diff --git a/app/contexts/FeatureFlagOverrideContext.test.tsx b/app/contexts/FeatureFlagOverrideContext.test.tsx index cfd79534f36b..5565c1774e8e 100644 --- a/app/contexts/FeatureFlagOverrideContext.test.tsx +++ b/app/contexts/FeatureFlagOverrideContext.test.tsx @@ -9,6 +9,7 @@ import { import { getFeatureFlagDescription, getFeatureFlagType, + isMinimumRequiredVersionSupported, } from '../util/feature-flags'; jest.mock('react-redux', () => ({ @@ -22,22 +23,47 @@ jest.mock('../selectors/featureFlagController', () => ({ jest.mock('../util/feature-flags', () => ({ getFeatureFlagDescription: jest.fn(), getFeatureFlagType: jest.fn(), + isMinimumRequiredVersionSupported: jest.fn(), })); +// Mock the ToastContext to avoid dependency issues +jest.mock('../component-library/components/Toast', () => { + const React = jest.requireActual('react'); + return { + ToastContext: React.createContext({ + toastRef: { current: { showToast: jest.fn() } }, + }), + ToastVariants: { + Plain: 'Plain', + Account: 'Account', + Network: 'Network', + App: 'App', + Icon: 'Icon', + }, + }; +}); + describe('FeatureFlagOverrideContext', () => { let mockUseSelector: jest.MockedFunction; let mockGetFeatureFlagDescription: jest.MockedFunction< typeof getFeatureFlagDescription >; let mockGetFeatureFlagType: jest.MockedFunction; + let mockIsMinimumRequiredVersionSupported: jest.MockedFunction< + typeof isMinimumRequiredVersionSupported + >; beforeEach(() => { jest.clearAllMocks(); mockUseSelector = jest.mocked(useSelector); mockGetFeatureFlagDescription = jest.mocked(getFeatureFlagDescription); mockGetFeatureFlagType = jest.mocked(getFeatureFlagType); + mockIsMinimumRequiredVersionSupported = jest.mocked( + isMinimumRequiredVersionSupported, + ); // Default mock implementations + mockIsMinimumRequiredVersionSupported.mockReturnValue(true); mockGetFeatureFlagDescription.mockImplementation( (key: string) => `Description for ${key}`, ); @@ -79,25 +105,17 @@ describe('FeatureFlagOverrideContext', () => { expect(Object.keys(result.current.featureFlags)).toHaveLength(5); }); - it('creates FeatureFlagInfo objects with correct structure', () => { + it('returns flag value directly from getFeatureFlag', () => { const mockFlags = { testFlag: true }; mockUseSelector.mockReturnValue(mockFlags); mockGetFeatureFlagType.mockReturnValue('boolean'); - mockGetFeatureFlagDescription.mockReturnValue('Test description'); const { result } = renderHook(() => useFeatureFlagOverride(), { wrapper: createWrapper, }); - const flagInfo = result.current.getFeatureFlag('testFlag'); - expect(flagInfo).toEqual({ - key: 'testFlag', - value: true, - originalValue: true, - type: 'boolean', - description: 'Test description', - isOverridden: false, - }); + const flagValue = result.current.getFeatureFlag('testFlag'); + expect(flagValue).toBe(true); }); it('sorts feature flags list alphabetically by key', () => { @@ -155,8 +173,8 @@ describe('FeatureFlagOverrideContext', () => { expect(result.current.hasOverride('testFlag')).toBe(true); expect(result.current.getOverride('testFlag')).toBe(false); expect(result.current.getOverrideCount()).toBe(1); - expect(result.current.getFeatureFlag('testFlag').value).toBe(false); - expect(result.current.getFeatureFlag('testFlag').isOverridden).toBe(true); + expect(result.current.getFeatureFlag('testFlag')).toBe(false); + expect(result.current.featureFlags.testFlag.isOverridden).toBe(true); }); it('sets override for non-existing flag', () => { @@ -172,11 +190,9 @@ describe('FeatureFlagOverrideContext', () => { expect(result.current.hasOverride('newFlag')).toBe(true); expect(result.current.getOverride('newFlag')).toBe('new value'); - expect(result.current.getFeatureFlag('newFlag').value).toBe('new value'); - expect( - result.current.getFeatureFlag('newFlag').originalValue, - ).toBeUndefined(); - expect(result.current.getFeatureFlag('newFlag').isOverridden).toBe(true); + expect(result.current.getFeatureFlag('newFlag')).toBe('new value'); + expect(result.current.featureFlags.newFlag.originalValue).toBeUndefined(); + expect(result.current.featureFlags.newFlag.isOverridden).toBe(true); }); it('removes override and restores original value', () => { @@ -191,7 +207,7 @@ describe('FeatureFlagOverrideContext', () => { result.current.setOverride('testFlag', false); }); - expect(result.current.getFeatureFlag('testFlag').value).toBe(false); + expect(result.current.getFeatureFlag('testFlag')).toBe(false); act(() => { result.current.removeOverride('testFlag'); @@ -199,10 +215,8 @@ describe('FeatureFlagOverrideContext', () => { expect(result.current.hasOverride('testFlag')).toBe(false); expect(result.current.getOverride('testFlag')).toBeUndefined(); - expect(result.current.getFeatureFlag('testFlag').value).toBe(true); - expect(result.current.getFeatureFlag('testFlag').isOverridden).toBe( - false, - ); + expect(result.current.getFeatureFlag('testFlag')).toBe(true); + expect(result.current.featureFlags.testFlag.isOverridden).toBe(false); }); it('removes non-existing override without error', () => { @@ -245,8 +259,8 @@ describe('FeatureFlagOverrideContext', () => { expect(result.current.hasOverride('flag1')).toBe(false); expect(result.current.hasOverride('flag2')).toBe(false); expect(result.current.hasOverride('flag3')).toBe(false); - expect(result.current.getFeatureFlag('flag1').value).toBe(true); - expect(result.current.getFeatureFlag('flag2').value).toBe(false); + expect(result.current.getFeatureFlag('flag1')).toBe(true); + expect(result.current.getFeatureFlag('flag2')).toBe(false); }); it('updates multiple overrides independently', () => { @@ -261,22 +275,22 @@ describe('FeatureFlagOverrideContext', () => { result.current.setOverride('flag1', 'override1'); }); - expect(result.current.getFeatureFlag('flag1').value).toBe('override1'); - expect(result.current.getFeatureFlag('flag2').value).toBe('original2'); + expect(result.current.getFeatureFlag('flag1')).toBe('override1'); + expect(result.current.getFeatureFlag('flag2')).toBe('original2'); act(() => { result.current.setOverride('flag2', 'override2'); }); - expect(result.current.getFeatureFlag('flag1').value).toBe('override1'); - expect(result.current.getFeatureFlag('flag2').value).toBe('override2'); + expect(result.current.getFeatureFlag('flag1')).toBe('override1'); + expect(result.current.getFeatureFlag('flag2')).toBe('override2'); act(() => { result.current.setOverride('flag1', 'updated1'); }); - expect(result.current.getFeatureFlag('flag1').value).toBe('updated1'); - expect(result.current.getFeatureFlag('flag2').value).toBe('override2'); + expect(result.current.getFeatureFlag('flag1')).toBe('updated1'); + expect(result.current.getFeatureFlag('flag2')).toBe('override2'); }); }); @@ -426,11 +440,11 @@ describe('FeatureFlagOverrideContext', () => { expect(mockGetFeatureFlagType).toHaveBeenCalledWith([1, 2, 3]); expect(mockGetFeatureFlagType).toHaveBeenCalledWith({ key: 'value' }); - expect(result.current.getFeatureFlag('booleanFlag').type).toBe('boolean'); - expect(result.current.getFeatureFlag('stringFlag').type).toBe('string'); - expect(result.current.getFeatureFlag('numberFlag').type).toBe('number'); - expect(result.current.getFeatureFlag('arrayFlag').type).toBe('array'); - expect(result.current.getFeatureFlag('objectFlag').type).toBe('object'); + expect(result.current.featureFlags.booleanFlag.type).toBe('boolean'); + expect(result.current.featureFlags.stringFlag.type).toBe('string'); + expect(result.current.featureFlags.numberFlag.type).toBe('number'); + expect(result.current.featureFlags.arrayFlag.type).toBe('array'); + expect(result.current.featureFlags.objectFlag.type).toBe('object'); }); it('calls getFeatureFlagDescription for each flag', () => { @@ -456,10 +470,8 @@ describe('FeatureFlagOverrideContext', () => { wrapper: createWrapper, }); - expect(result.current.getFeatureFlag('nullFlag').value).toBeNull(); - expect( - result.current.getFeatureFlag('undefinedFlag').value, - ).toBeUndefined(); + expect(result.current.getFeatureFlag('nullFlag')).toBeNull(); + expect(result.current.getFeatureFlag('undefinedFlag')).toBeUndefined(); }); it('uses original value for type determination when current value is null/undefined', () => { @@ -488,7 +500,7 @@ describe('FeatureFlagOverrideContext', () => { wrapper: createWrapper, }); - expect(result.current.getFeatureFlag('flag1').value).toBe(true); + expect(result.current.getFeatureFlag('flag1')).toBe(true); expect(result.current.featureFlagsList).toHaveLength(1); // Change Redux state @@ -497,8 +509,8 @@ describe('FeatureFlagOverrideContext', () => { rerender(null); - expect(result.current.getFeatureFlag('flag1').value).toBe(true); - expect(result.current.getFeatureFlag('flag2').value).toBe(false); + expect(result.current.getFeatureFlag('flag1')).toBe(true); + expect(result.current.getFeatureFlag('flag2')).toBe(false); expect(result.current.featureFlagsList).toHaveLength(2); }); @@ -514,7 +526,7 @@ describe('FeatureFlagOverrideContext', () => { result.current.setOverride('flag1', false); }); - expect(result.current.getFeatureFlag('flag1').value).toBe(false); + expect(result.current.getFeatureFlag('flag1')).toBe(false); // Change Redux state const updatedFlags = { flag1: true, flag2: 'new' }; @@ -523,10 +535,10 @@ describe('FeatureFlagOverrideContext', () => { rerender(null); // Override should be preserved - expect(result.current.getFeatureFlag('flag1').value).toBe(false); - expect(result.current.getFeatureFlag('flag1').isOverridden).toBe(true); - expect(result.current.getFeatureFlag('flag2').value).toBe('new'); - expect(result.current.getFeatureFlag('flag2').isOverridden).toBe(false); + expect(result.current.getFeatureFlag('flag1')).toBe(false); + expect(result.current.featureFlags.flag1.isOverridden).toBe(true); + expect(result.current.getFeatureFlag('flag2')).toBe('new'); + expect(result.current.featureFlags.flag2.isOverridden).toBe(false); }); }); @@ -596,6 +608,136 @@ describe('FeatureFlagOverrideContext', () => { }); }); + describe('getFeatureFlag Method', () => { + it('returns undefined for non-existent flags', () => { + mockUseSelector.mockReturnValue({}); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('nonExistentFlag')).toBeUndefined(); + }); + + it('returns enabled value for boolean with minimumVersion flags when version is supported', () => { + const mockFlags = { + testFlag: { enabled: true, minimumVersion: '1.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(true); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('testFlag')).toBe(true); + }); + + it('returns false for boolean with minimumVersion flags when enabled is false and version is supported', () => { + const mockFlags = { + testFlag: { enabled: false, minimumVersion: '1.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(true); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('testFlag')).toBe(false); + }); + + it('returns false for boolean with minimumVersion flags when version is not supported in non-production', () => { + const originalNodeEnv = process.env.NODE_ENV; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', + configurable: true, + }); + + const mockFlags = { + testFlag: { enabled: true, minimumVersion: '99.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('testFlag')).toBe(false); + + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalNodeEnv, + configurable: true, + }); + }); + + it('returns false for boolean with minimumVersion flags when version is not supported in production', () => { + const originalNodeEnv = process.env.NODE_ENV; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'production', + configurable: true, + }); + + const mockFlags = { + testFlag: { enabled: true, minimumVersion: '99.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('testFlag')).toBe(false); + + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalNodeEnv, + configurable: true, + }); + }); + + it('returns enabled value for boolean with minimumVersion flags when enabled is false', () => { + const mockFlags = { + testFlag: { enabled: false, minimumVersion: '99.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('testFlag')).toBe(false); + }); + + it('returns flag value directly for non-boolean with minimumVersion flags', () => { + const mockFlags = { + stringFlag: 'test value', + numberFlag: 42, + booleanFlag: true, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType + .mockReturnValueOnce('string') + .mockReturnValueOnce('number') + .mockReturnValueOnce('boolean'); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('stringFlag')).toBe('test value'); + expect(result.current.getFeatureFlag('numberFlag')).toBe(42); + expect(result.current.getFeatureFlag('booleanFlag')).toBe(true); + }); + }); + describe('Complex Scenarios', () => { it('handles mix of original flags, overrides, and new flags', () => { const mockFlags = { @@ -617,20 +759,21 @@ describe('FeatureFlagOverrideContext', () => { const allFlags = result.current.featureFlagsList; expect(allFlags).toHaveLength(3); - const original1 = result.current.getFeatureFlag('original1'); - expect(original1.value).toBe('modified1'); - expect(original1.originalValue).toBe('value1'); - expect(original1.isOverridden).toBe(true); + expect(result.current.getFeatureFlag('original1')).toBe('modified1'); + expect(result.current.featureFlags.original1.originalValue).toBe( + 'value1', + ); + expect(result.current.featureFlags.original1.isOverridden).toBe(true); - const original2 = result.current.getFeatureFlag('original2'); - expect(original2.value).toBe('value2'); - expect(original2.originalValue).toBe('value2'); - expect(original2.isOverridden).toBe(false); + expect(result.current.getFeatureFlag('original2')).toBe('value2'); + expect(result.current.featureFlags.original2.originalValue).toBe( + 'value2', + ); + expect(result.current.featureFlags.original2.isOverridden).toBe(false); - const new1 = result.current.getFeatureFlag('new1'); - expect(new1.value).toBe('newValue1'); - expect(new1.originalValue).toBeUndefined(); - expect(new1.isOverridden).toBe(true); + expect(result.current.getFeatureFlag('new1')).toBe('newValue1'); + expect(result.current.featureFlags.new1.originalValue).toBeUndefined(); + expect(result.current.featureFlags.new1.isOverridden).toBe(true); }); it('handles rapid override changes', () => { @@ -646,7 +789,7 @@ describe('FeatureFlagOverrideContext', () => { result.current.setOverride('testFlag', 'change3'); }); - expect(result.current.getFeatureFlag('testFlag').value).toBe('change3'); + expect(result.current.getFeatureFlag('testFlag')).toBe('change3'); expect(result.current.getOverrideCount()).toBe(1); }); @@ -657,14 +800,72 @@ describe('FeatureFlagOverrideContext', () => { wrapper: createWrapper, }); - expect(result.current.getFeatureFlag('').value).toBe('empty key value'); + expect(result.current.getFeatureFlag('')).toBe('empty key value'); act(() => { result.current.setOverride('', 'overridden empty'); }); - expect(result.current.getFeatureFlag('').value).toBe('overridden empty'); + expect(result.current.getFeatureFlag('')).toBe('overridden empty'); expect(result.current.hasOverride('')).toBe(true); }); + + describe('Version Support Logic', () => { + it('returns false for unsupported minimumVersion in development environment', () => { + const originalNodeEnv = process.env.NODE_ENV; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', + configurable: true, + }); + + const mockFlags = { + myFeatureFlag: { enabled: true, minimumVersion: '10.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('myFeatureFlag')).toBe(false); + + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalNodeEnv, + configurable: true, + }); + }); + + it('returns enabled value when feature flag version is supported', () => { + const mockFlags = { + supportedFlag: { enabled: true, minimumVersion: '1.0.0' }, + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('boolean with minimumVersion'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(true); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('supportedFlag')).toBe(true); + }); + + it('returns flag value directly for non-boolean with minimumVersion flag types', () => { + const mockFlags = { + stringFlag: 'test value', + }; + mockUseSelector.mockReturnValue(mockFlags); + mockGetFeatureFlagType.mockReturnValue('string'); + mockIsMinimumRequiredVersionSupported.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlagOverride(), { + wrapper: createWrapper, + }); + + expect(result.current.getFeatureFlag('stringFlag')).toBe('test value'); + }); + }); }); }); diff --git a/app/contexts/FeatureFlagOverrideContext.tsx b/app/contexts/FeatureFlagOverrideContext.tsx index b36d7aeadfcc..79bf1deead26 100644 --- a/app/contexts/FeatureFlagOverrideContext.tsx +++ b/app/contexts/FeatureFlagOverrideContext.tsx @@ -12,7 +12,13 @@ import { FeatureFlagInfo, getFeatureFlagDescription, getFeatureFlagType, + isMinimumRequiredVersionSupported, } from '../util/feature-flags'; +import { + ToastContext, + ToastVariants, +} from '../component-library/components/Toast'; +import { MinimumVersionFlagValue } from '../components/Views/FeatureFlagOverride/FeatureFlagOverride'; interface FeatureFlagOverrides { [key: string]: unknown; @@ -21,7 +27,7 @@ interface FeatureFlagOverrides { export interface FeatureFlagOverrideContextType { featureFlags: { [key: string]: FeatureFlagInfo }; originalFlags: FeatureFlagOverrides; - getFeatureFlag: (key: string) => FeatureFlagInfo; + getFeatureFlag: (key: string) => unknown; featureFlagsList: FeatureFlagInfo[]; overrides: FeatureFlagOverrides; setOverride: (key: string, value: unknown) => void; @@ -47,6 +53,8 @@ export const FeatureFlagOverrideProvider: React.FC< > = ({ children }) => { // Get the initial feature flags from Redux const rawFeatureFlags = useSelector(selectRemoteFeatureFlags); + const toastContext = useContext(ToastContext); + const toastRef = toastContext?.toastRef; // Local state for overrides const [overrides, setOverrides] = useState({}); @@ -135,10 +143,50 @@ export const FeatureFlagOverrideProvider: React.FC< a.key.localeCompare(b.key), ); + const validateMinimumVersion = useCallback( + (flagKey: string, flagValue: MinimumVersionFlagValue) => { + if ( + process.env.NODE_ENV !== 'production' && + !isMinimumRequiredVersionSupported(flagValue.minimumVersion) + ) { + toastRef?.current?.showToast({ + labelOptions: [ + { + label: 'Unsupported version', + isBold: true, + }, + { + label: `${flagKey} is not supported on your version of the app.`, + }, + ], + hasNoTimeout: false, + variant: ToastVariants.Plain, + }); + return false; + } + return flagValue.enabled; + }, + [toastRef], + ); + /** * get a specific feature flag value with overrides applied */ - const getFeatureFlag = (key: string) => featureFlags[key]; + const getFeatureFlag = (key: string) => { + const flag = featureFlags[key]; + if (!flag) { + return undefined; + } + + if (flag.type === 'boolean with minimumVersion') { + return validateMinimumVersion( + flag.key, + flag.value as unknown as MinimumVersionFlagValue, + ); + } + + return flag.value; + }; const getOverrideCount = useCallback( (): number => Object.keys(overrides).length,