Skip to content

Commit

Permalink
Adds feature flags hook (#206)
Browse files Browse the repository at this point in the history
# Description

Adds `useFeatureFlags` hook to easily enable and disable features in the
app based on environment variables. This will enable us to continuously
build and release changes without long lived feature branches.

This is a very basic feature flag implementation based on an environment
variable `FEATURES_ENABLED` which has been added to `.env.example`, but
it allows us to turn on features locally and in a future "Canary" app
build whilst having the code merged and released to production with
feature turned off. Other hosted options give more flexibility but come
at a cost 💵

Once merged, we can attempt a build and release to app stores which will
get code in the wild along with some other improvements that have been
merged since last release, without adding unfinished features.

**Expo limitations**

Since this uses an environment variable, which is only loaded once when
the Expo cli is started (`npm run start`), changing the value will
require restarting expo cli and Expo Go on simulator. In practice we
shouldn't be changing this much during development, except to add a new
feature flag, so this should only cause pain during initial testing.

## Type of change

Please delete options that are not relevant

- [] Bug fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [] Breaking change(fix or feature that would cause existing
functionality to not work as expected)
- [] This change requires a documentation update

# Testing locally

Update your `.env` file with suggested `FEATURES_ENABLED` from
`.env.example`, restart Expo cli and Expo Go, verify `Profile` and
`Events` tabs appear in bottom nav.

Remove `FEATURES_ENABLED` from `.env` (or make it's value empty),
restart Expo cli and Expo Go, verify `Profile` and `Events` tabs **do
not** appear in bottom nav. This is the config that we will initially
release to app stores.

# Checklist

- [] My code follows the style guidelines of this project
- [] I have performed a self-review of my own code
- [] I have commented my code, particularly in hard-to-understand areas
- [] I have removed any unnecessary comments or console logging
- [] I have made corresponding changes to the documentation (if
required)
- [] I have addressed accessibility, if needed
- [] I have followed best practices, e.g. NativeBase approaches and
theming
- [] I have checked the app in dark mode, if making front-end design
changes
- [] My changes generate no new warnings
- [] I have added tests that prove my fix is effective or that my
feature works
- [] New and existing unit tests pass locally with my changes
- [] I have updated the version numbers in `package.json` files in the
[app](DEPLOYMENT.md#app-deployment) and/or
[api](DEPLOYMENT.md#api-deployment-on-aws) directories as needed
  • Loading branch information
roryf authored Dec 5, 2023
1 parent a13bc5d commit bda7434
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 19 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ EXPO_APPLICATION_SERVICES_PROJECT_ID=""

STA_API_BASE_URL=""
STA_API_VERSION="v1"
STA_API_KEY=""
STA_API_KEY=""

FEATURES_ENABLED=events,profileScreen
7 changes: 6 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { isDevelopmentMode } from '@/Utils/Expo'
import { isJson } from '@/Utils/Json'
import { version } from './package.json'
import { AuthClient, AuthProvider } from '@/Services/auth'
import { FeatureFlagsProvider } from '@/Services/featureFlags'

SplashScreen.preventAutoHideAsync()

Expand Down Expand Up @@ -147,7 +148,11 @@ const App = () => {
theme={StaTheme}
>
<AuthProvider client={authClient} blockUntilInitialised={true}>
<ApplicationNavigator />
<FeatureFlagsProvider
features={Constants.expoConfig?.extra?.features}
>
<ApplicationNavigator />
</FeatureFlagsProvider>
</AuthProvider>
</NativeBaseProvider>
</PersistGate>
Expand Down
5 changes: 5 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import 'dotenv/config'
import { version } from './package.json'

const enabledFeatures = !process.env.FEATURES_ENABLED
? []
: process.env.FEATURES_ENABLED.split(',')

module.exports = {
expo: {
name: 'STA Volunteers',
Expand Down Expand Up @@ -42,6 +46,7 @@ module.exports = {
favicon: './assets/favicon.png',
},
extra: {
features: enabledFeatures,
api: {
baseUrl: process.env.STA_API_BASE_URL ?? 'https://the-sta.com',
version: process.env.STA_API_VERSION ?? 'v1',
Expand Down
39 changes: 22 additions & 17 deletions src/Navigators/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import StaTheme from '@/NativeBase/Theme/StaTheme'
import { Platform } from 'react-native'
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'
import MyProfile from '@/NativeBase/Containers/ProfileContainer'
import { useFeatureFlags } from '@/Services/featureFlags'

const Tab = createBottomTabNavigator()

Expand Down Expand Up @@ -122,6 +123,7 @@ const BottomTabLabel = ({ focused }: BottomTabOptionsProps) => {
* @returns {import('@react-navigation/bottom-tabs').BottomTabNavigator} A bottom tab navigator component from the '@react-navigation/bottom-tabs' package
*/
const MainNavigator = () => {
const featureFlags = useFeatureFlags()
const bottomTabOptions = {
tabBarIcon: props => BottomTabIcon(props),
tabBarLabel: props => BottomTabLabel(props),
Expand Down Expand Up @@ -149,23 +151,26 @@ const MainNavigator = () => {
...bottomTabOptions,
}}
/>
{/* <Tab.Screen
name="Events"
component={ListContainer}
initialParams={{ type: ListType.Events }}
options={{
...bottomTabOptions,
}}
/> */}
{/* @TODO: Make visible for MVP+1 */}
<Tab.Screen
name="Profile"
component={MyProfile}
options={{
headerShown: false,
...bottomTabOptions,
}}
/>
{featureFlags.events && (
<Tab.Screen
name="Events"
component={ListContainer}
initialParams={{ type: ListType.Events }}
options={{
...bottomTabOptions,
}}
/>
)}
{featureFlags.profileScreen && (
<Tab.Screen
name="Profile"
component={MyProfile}
options={{
headerShown: false,
...bottomTabOptions,
}}
/>
)}
<Tab.Screen
name="Settings"
component={SettingsContainer}
Expand Down
1 change: 1 addition & 0 deletions src/Services/featureFlags/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useFeatureFlags'
20 changes: 20 additions & 0 deletions src/Services/featureFlags/useFeatureFlags.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react'
import { renderHook } from '@testing-library/react-native'
import { useFeatureFlags, FeatureFlagsProvider } from './useFeatureFlags'

describe('useFeatureFlags', () => {
test('uses features config or defaults to false', () => {
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: ({ children }) => (
<FeatureFlagsProvider features={['profileScreen']}>
{children}
</FeatureFlagsProvider>
),
})

expect(result.current).toEqual({
profileScreen: true,
events: false,
})
})
})
60 changes: 60 additions & 0 deletions src/Services/featureFlags/useFeatureFlags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, { createContext, useContext, useEffect, useState } from 'react'

export enum FeatureFlags {
'profileScreen' = 'profileScreen',
'events' = 'events',
}

type FeatureFlagsType = {
[key in keyof typeof FeatureFlags]: boolean
}

interface FeatureFlagsContextType {
features: FeatureFlagsType
}

const defaultFeatures = Object.keys(FeatureFlags).reduce(
(acc, key) => ({
...acc,
[key]: false,
}),
{} as FeatureFlagsType,
)

export const FeatureFlagsContext = createContext<FeatureFlagsContextType>({
features: defaultFeatures,
})

export const useFeatureFlags = () => {
const { features } = useContext(FeatureFlagsContext)
return features
}

export const FeatureFlagsProvider = ({
features,
children,
}: {
features: string[]
children?: React.ReactNode
}) => {
console.log(features)
const [context, setContext] = useState<FeatureFlagsType>(defaultFeatures)

useEffect(() => {
setContext(
Object.keys(FeatureFlags).reduce(
(acc, key) => ({
...acc,
[key]: features.includes(key),
}),
{} as FeatureFlagsType,
),
)
}, [features])

return (
<FeatureFlagsContext.Provider value={{ features: context }}>
{children}
</FeatureFlagsContext.Provider>
)
}

0 comments on commit bda7434

Please sign in to comment.