Skip to content

Commit

Permalink
Build time feature flags (Phase 1) (#694)
Browse files Browse the repository at this point in the history
  • Loading branch information
tstirrat authored May 3, 2020
1 parent b579f9b commit b554502
Show file tree
Hide file tree
Showing 18 changed files with 1,077 additions and 18 deletions.
10 changes: 10 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This is the debug/dev .env file. Any debug or non-release-signed build will
# use these flags.

# About flags:
# ------------
# Any variables that start with flag_ will be available to the <Flag> component
# For now, only true/1 is parsed, everything else will be interpretted as false
# flags must begin with flag_ and are case sensitive

flag_google_import=true
11 changes: 11 additions & 0 deletions .env.beta
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This is the BETA .env file, it is built for TestFlight and Google Play beta
# channels. Flags that are ready for testing/beta should be enabled here.

# When making any BETA channel build on both iOS or Android, prepend the build
# command with ENVFILE=.env.beta
#
# e.g.
#
# ENVFILE=.env.beta ./gradlew assembleRelease

# flag_google_import=true
5 changes: 5 additions & 0 deletions .env.release
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# This is the release .env file, it is built for App store final releases
# only prod ready feature flags should be enabled here.
#
# For Android, this file is automatically used for any release build. iOS should
# prepend ENVFILE=.env.release before any build commands.
4 changes: 3 additions & 1 deletion .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
env:
ENVFILE: .env.release
steps:
- name: Checkout code
uses: actions/checkout@v2
Expand Down Expand Up @@ -76,7 +78,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: android/app/build/outputs/apk/release/app-release-unsigned-signed.apk
asset_name: app-release-${{ github.ref }}.apk
asset_content_type: application/zip
Expand Down
21 changes: 8 additions & 13 deletions App.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
* @flow
*/

import React, { useEffect } from 'react';
import { MenuProvider } from 'react-native-popup-menu';
import SplashScreen from 'react-native-splash-screen';

import { Theme } from './app/constants/themes';
import Entry from './app/Entry';
import { FlagsProvider, buildTimeFlags } from './app/helpers/flags';
import VersionCheckService from './app/services/VersionCheckService';

const App = () => {
Expand All @@ -21,11 +14,13 @@ const App = () => {
}, []);

return (
<MenuProvider>
<Theme use='default'>
<Entry />
</Theme>
</MenuProvider>
<FlagsProvider flags={buildTimeFlags}>
<MenuProvider>
<Theme use='default'>
<Entry />
</Theme>
</MenuProvider>
</FlagsProvider>
);
};

Expand Down
3 changes: 3 additions & 0 deletions __mocks__/react-native-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
FLAG_FOO_BAR: 'true',
};
7 changes: 7 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
apply plugin: "com.android.application"

project.ext.envConfigFiles = [
debug: ".env",
release: ".env.release",
]

apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"

import com.android.build.OutputFile

/**
Expand Down
3 changes: 3 additions & 0 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@
# http://developer.android.com/guide/developing/tools/proguard.html

# Add any project specific keep options here:

# react-native-config, prevents obfuscating of .env flags
-keep class org.pathcheck.covidsafepaths.BuildConfig { *; }
28 changes: 28 additions & 0 deletions app/components/Feature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';

import { Flag } from '../helpers/flags';

/**
* Small wrapper around `<Flag />` which makes the default case easier:
*
* Usage:
*
* ```
* <Feature name="google_import" fallback={() => <hr />}>
* <FeatureUi />
* </Feature>
* ```
*
* @param {{
* name: string;
* fallback?: () => import('react').ReactNode;
* children: import('react').ReactNode;
* }} param0
*/
export const Feature = ({ name, fallback, children }) => {
const keyPath = name.split('.');

return (
<Flag name={keyPath} render={() => children} fallbackRender={fallback} />
);
};
78 changes: 78 additions & 0 deletions app/components/__tests__/Feature.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render } from '@testing-library/react-native';
import React from 'react';
import { Text } from 'react-native';

import { FlagsProvider } from '../../helpers/flags';
import { Feature } from '../Feature';

it('renders feature if the flag is enabled', () => {
const { asJSON } = render(
<FlagsProvider flags={{ feature1: true }}>
<Feature name='feature1'>
<Text>feature1</Text>
</Feature>
</FlagsProvider>,
);

expect(asJSON()).toMatchSnapshot();
});

it('allows dotted notation in the feature name', () => {
const { asJSON } = render(
<FlagsProvider flags={{ feature1: { child: true } }}>
<Feature name='feature1.child'>
<Text>feature1.child</Text>
</Feature>
</FlagsProvider>,
);

expect(asJSON()).toMatchSnapshot();
});

it('does not render if dotted notation key path is falsy', () => {
const { asJSON } = render(
<FlagsProvider flags={{ feature1: {} }}>
<Feature name='feature1.child'>
<Text>feature1.child</Text>
</Feature>
</FlagsProvider>,
);

expect(asJSON()).toMatchSnapshot();
});

it('omits feature if the flag is disabled', () => {
const { asJSON } = render(
<FlagsProvider flags={{ feature1: false }}>
<Feature name='feature1'>
<Text>feature1</Text>
</Feature>
</FlagsProvider>,
);

expect(asJSON()).toMatchSnapshot();
});

it('omits feature if the flag is missing', () => {
const { asJSON } = render(
<FlagsProvider flags={{}}>
<Feature name='feature1'>
<Text>feature1</Text>
</Feature>
</FlagsProvider>,
);

expect(asJSON()).toMatchSnapshot();
});

it('renders the fallback instead, if the flag is disabled/omitted', () => {
const { asJSON } = render(
<FlagsProvider flags={{ feature1: false }}>
<Feature name='feature1' fallback={() => <Text>Old UI</Text>}>
<Text>feature1</Text>
</Feature>
</FlagsProvider>,
);

expect(asJSON()).toMatchSnapshot();
});
85 changes: 85 additions & 0 deletions app/components/__tests__/__snapshots__/Feature.spec.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`allows dotted notation in the feature name 1`] = `
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
>
<Text>
feature1.child
</Text>
</View>
`;

exports[`does not render if dotted notation key path is falsy 1`] = `
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
/>
`;

exports[`omits feature if the flag is disabled 1`] = `
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
/>
`;

exports[`omits feature if the flag is missing 1`] = `
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
/>
`;

exports[`renders feature if the flag is enabled 1`] = `
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
>
<Text>
feature1
</Text>
</View>
`;

exports[`renders the fallback instead, if the flag is disabled/omitted 1`] = `
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
>
<Text>
Old UI
</Text>
</View>
`;
28 changes: 28 additions & 0 deletions app/helpers/flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import createFlags from 'flag';
import env from 'react-native-config';

const { FlagsProvider, Flag, useFlag, useFlags } = createFlags();

/**
* Normalizes flags:
*
* Example:
*
* `{ flag_a: 'true' }` becomes `{ a: true }`
*
* @param {{[key: string]: string}} envConfig
*/
export function parseFlags(envConfig) {
return Object.entries(envConfig)
.filter(([key]) => key.toLowerCase().startsWith('flag'))
.reduce((flags, [key, value]) => {
const flag = key.replace(/^flag_/i, '');

flags[flag] = value === 'true' || value === '1';
return flags;
}, {});
}

export const buildTimeFlags = parseFlags(env);

export { FlagsProvider, Flag, useFlag, useFlags };
6 changes: 3 additions & 3 deletions app/views/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import checkmarkIcon from '../assets/svgs/checkmarkIcon';
import languagesIcon from '../assets/svgs/languagesIcon';
import xmarkIcon from '../assets/svgs/xmarkIcon';
import { Divider } from '../components/Divider';
import { Feature } from '../components/Feature';
import NativePicker from '../components/NativePicker';
import NavigationBarWrapper from '../components/NavigationBarWrapper';
import Colors from '../constants/colors';
Expand Down Expand Up @@ -102,7 +103,6 @@ export const SettingsScreen = ({ navigation }) => {
)}
</NativePicker>
</Section>

<Section>
<Item
label={t('label.choose_provider_title')}
Expand All @@ -127,11 +127,11 @@ export const SettingsScreen = ({ navigation }) => {
/>
</Section>

{__DEV__ && (
<Feature name='google_import'>
<Section>
<GoogleMapsImport navigation={navigation} />
</Section>
)}
</Feature>

<Section last>
<Item
Expand Down
21 changes: 20 additions & 1 deletion app/views/__tests__/Settings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'react-native';
import { act, render } from '@testing-library/react-native';
import React from 'react';

import { FlagsProvider } from '../../helpers/flags';
import * as languages from '../../locales/languages';
import { SettingsScreen } from '../Settings';

Expand Down Expand Up @@ -36,7 +37,25 @@ afterEach(() => {
});

it('renders correctly', async () => {
const { asJSON } = render(<SettingsScreen />);
const { asJSON } = render(
<FlagsProvider flags={{ google_import: true }}>
<SettingsScreen />
</FlagsProvider>,
);

await act(async () => {
jest.runAllTimers();
});

await expect(asJSON()).toMatchSnapshot();
});

it('renders correctly (without google import flag)', async () => {
const { asJSON } = render(
<FlagsProvider flags={{}}>
<SettingsScreen />
</FlagsProvider>,
);

await act(async () => {
jest.runAllTimers();
Expand Down
Loading

0 comments on commit b554502

Please sign in to comment.