Skip to content

Commit eb830ea

Browse files
authored
Merge pull request #7087 from StoDevX/drew/feature-flags
🧪 internal developer feature flagging
2 parents c323606 + 8cc34c2 commit eb830ea

File tree

16 files changed

+605
-175
lines changed

16 files changed

+605
-175
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, {ReactElement} from 'react'
2+
import {describe, expect, test} from '@jest/globals'
3+
import {renderHook, waitFor} from '@testing-library/react-native'
4+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
5+
6+
import {useFeature} from '../index'
7+
import {AppConfigEntry} from '../types'
8+
9+
jest.mock('../../../modules/constants', () => ({
10+
isDevMode: jest.fn(),
11+
}))
12+
13+
jest.mock('../../../source/lib/storage', () => ({
14+
getFeatureFlag: jest.fn(),
15+
}))
16+
17+
describe('useCourseSearchRecentsScreen', () => {
18+
let queryClient: QueryClient
19+
20+
beforeAll(() => {
21+
queryClient = new QueryClient()
22+
})
23+
24+
afterAll(() => {
25+
queryClient.clear()
26+
queryClient.removeQueries()
27+
})
28+
29+
const queryWrapper = ({children}: {children: ReactElement}) => (
30+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
31+
)
32+
33+
// eslint-disable-next-line @typescript-eslint/no-var-requires
34+
const isDevModeMock = require('../../../modules/constants').isDevMode
35+
const getFeatureFlagMock =
36+
// eslint-disable-next-line @typescript-eslint/no-var-requires
37+
require('../../../source/lib/storage').getFeatureFlag
38+
39+
test('it should return true in dev when feature is enabled', async () => {
40+
isDevModeMock.mockReturnValue(true)
41+
getFeatureFlagMock.mockReturnValue(true)
42+
43+
const {result} = renderHook(
44+
() => useFeature(AppConfigEntry.Courses_ShowRecentSearchScreen),
45+
{
46+
wrapper: queryWrapper,
47+
},
48+
)
49+
50+
await waitFor(() => {
51+
expect(result.current).toBe(true)
52+
})
53+
})
54+
55+
test('it should return false in dev when feature is disabled', async () => {
56+
isDevModeMock.mockReturnValue(true)
57+
getFeatureFlagMock.mockReturnValue(false)
58+
59+
const {result} = renderHook(
60+
() => useFeature(AppConfigEntry.Courses_ShowRecentSearchScreen),
61+
{
62+
wrapper: queryWrapper,
63+
},
64+
)
65+
66+
await waitFor(() => {
67+
expect(result.current).toBe(false)
68+
})
69+
})
70+
})

modules/app-config/index.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {getFeatureFlag} from '../../source/lib/storage'
2+
import {AppConfigEntry, FeatureFlag} from './types'
3+
import {useQuery} from '@tanstack/react-query'
4+
import {isDevMode} from '@frogpond/constants'
5+
6+
export type {AppConfigEntry, FeatureFlag} from './types'
7+
8+
// helper method to query exported __DEV__ feature flags
9+
export const useFeature = (featureKey: AppConfigEntry): boolean => {
10+
let {data: featureValue = false} = useQuery({
11+
queryKey: ['app', 'app:feature-flag', featureKey],
12+
queryFn: () => getFeatureFlag(featureKey),
13+
onSuccess: (newValue) => {
14+
return isDevMode() ? newValue : false
15+
},
16+
})
17+
18+
return isDevMode() ? featureValue : false
19+
}
20+
21+
// datastore for the __DEV__ feature flags
22+
export const AppConfig = async (): Promise<FeatureFlag[]> => {
23+
if (!isDevMode()) {
24+
return []
25+
}
26+
27+
return [
28+
{
29+
title: 'Show the course search recents screen',
30+
configKey: AppConfigEntry.Courses_ShowRecentSearchScreen,
31+
active: await getFeatureFlag(
32+
AppConfigEntry.Courses_ShowRecentSearchScreen,
33+
),
34+
},
35+
]
36+
}
37+
38+
// exported feature flags
39+
export const useCourseSearchRecentsScreen = (): boolean =>
40+
useFeature(AppConfigEntry.Courses_ShowRecentSearchScreen)

modules/app-config/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "@frogpond/app-config",
3+
"version": "1.0.0",
4+
"description": "",
5+
"main": "index.ts",
6+
"author": "",
7+
"license": "ISC",
8+
"scripts": {
9+
"test": "jest"
10+
},
11+
"peerDependencies": {
12+
"react": "^18.0.0",
13+
"react-native": "^0.71.7"
14+
},
15+
"dependencies": {}
16+
}

modules/app-config/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface FeatureFlag {
2+
configKey: AppConfigEntry
3+
title: string
4+
active: boolean
5+
}
6+
7+
/**
8+
* __DEV__ app config keys
9+
*
10+
* Reserved for dev-only flags for internal experimentation, these
11+
* will never be A/B flags nor will they return true in production.
12+
*
13+
* The format is SECTION_KEY where the first underscore will be
14+
* split at render time for our view to group by, but the entirety
15+
* of the value (section + key) will be stored.
16+
*/
17+
export enum AppConfigEntry {
18+
Courses_ShowRecentSearchScreen = 'Courses_ShowRecentSearchScreen',
19+
}

package-lock.json

Lines changed: 115 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"@jest/globals": "29.6.4",
114114
"@sentry/cli": "2.20.6",
115115
"@tanstack/eslint-plugin-query": "4.34.1",
116+
"@testing-library/react-native": "12.3.0",
116117
"@types/base-64": "1.0.0",
117118
"@types/http-cache-semantics": "4.0.1",
118119
"@types/jest": "29.5.4",

source/lib/storage.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,29 @@ import {
66
setItem,
77
setStoragePrefix,
88
} from '@frogpond/storage'
9+
import {AppConfigEntry} from '@frogpond/app-config'
910
import type {FilterComboType} from '../views/sis/course-search/lib/format-filter-combo'
1011
import type {CourseType, TermType} from './course-search/types'
1112

1213
export {clearAsyncStorage}
1314

1415
setStoragePrefix('aao:')
1516

17+
/// MARK: Feature flags
18+
19+
const featureFlagsKey = 'app:feature-flag'
20+
export function setFeatureFlag(
21+
name: AppConfigEntry,
22+
value: boolean,
23+
): Promise<void> {
24+
const key = `${featureFlagsKey}:${name}`
25+
return setItem(key, value)
26+
}
27+
export function getFeatureFlag(name: AppConfigEntry): Promise<boolean> {
28+
const key = `${featureFlagsKey}:${name}`
29+
return getItemAsBoolean(key)
30+
}
31+
1632
/// MARK: Settings
1733

1834
const homescreenOrderKey = 'homescreen:view-order'

0 commit comments

Comments
 (0)