Skip to content

Commit

Permalink
Adding Simple URL-based feature flags (#117)
Browse files Browse the repository at this point in the history
* Fixes #66: As a developer, I want to limit the audience
that sees new features, so that we can control
the message and positioning of our tool.
Implements simple feature flagging via URL parameters.
Provide "?flags=x,y,z" to enable flags x, y, and z.
* Fixing type to use Location instead of URL
* Updating README with info on how to use feature flags
  • Loading branch information
NatHillardUSDS authored Jun 9, 2021
1 parent c07a14a commit 7ab14c7
Show file tree
Hide file tree
Showing 11 changed files with 202 additions and 31 deletions.
13 changes: 13 additions & 0 deletions client/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
"stopOnEntry": false,
"runtimeArgs": ["--nolazy"],
"sourceMaps": false
},
{
"name": "Debug Jest Tests",
"type": "node",
"request": "launch",
"runtimeArgs": [
"--inspect-brk",
"--inspect",
"${workspaceRoot}/node_modules/.bin/jest"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"port": 9229
}
]
}
23 changes: 23 additions & 0 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,26 @@ From there, send `src/intl/en.json` to translators. (Depending on the TMS (Trans
To access a translated version of a page, e.g. `pages/index.js`, add the locale as a portion of the URL path, as follows:

- English: `localhost:8000/en/`, or `localhost:8000/` (the default fallback is English)

## Feature Toggling

We have implemented very simple feature flagging for this app, accessible via URL parameters.

There are a lot of benefits to using feature toggles -- see [Martin Fowler](https://martinfowler.com/articles/feature-toggles.html) for a longer justification, but in short, they enable shipping in-progress work to production without enabling particular features for all users.

### Viewing Features

To view features, add the `flags` parameter to the URL, and set the value to a comma-delimited list of features to enable, e.g. `localhost:8000?flags=1,2,3` will enable features 1, 2, and 3.

In the future we may add other means of audience-targeting, but for now we will be sharing links with flags enabled as a means of sharing in-development funcitonality

### Using Flags

When developing, to use a flag:

1. Pass the Gatsby-provided `location` variable to your component. You have several options here:
1. If your page uses the `Layout` [component](src/components/layout.tsx), you automatically get `URLFlagProvider` (see [FlagContext](src/contexts/FlagContext.tsx) for more info).
2. If your page does not use `Layout`, you need to surround your component with a `URLFlagProvider` component and pass `location`. You can get `location` from the default props of the page (more [here](https://www.gatsbyjs.com/docs/location-data-from-props/)). See [Index.tsx](src/pages/index.tsx) for an example.
2. Use the `useFlags()` hook to get access to an array of flags, and check this array for the presence of the correct feature identifier. See [J40Header](src/components/J40Header.tsx) for an example.

And that's it!
43 changes: 31 additions & 12 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
},
"dependencies": {
"@trussworks/react-uswds": "github:nathillardusds/react-uswds#nathillardusds/ssr",
"query-string": "^7.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-helmet": "^6.1.0",
Expand Down
18 changes: 13 additions & 5 deletions client/src/components/J40Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ import {GovBanner,
Title,
PrimaryNav,
SiteAlert} from '@trussworks/react-uswds';
import {useIntl} from 'gatsby-plugin-intl';
import {useIntl, Link} from 'gatsby-plugin-intl';
import {Helmet} from 'react-helmet';
const headerLinks = [
<></>,
];
import {useFlags} from '../contexts/FlagContext';

const headerLinks = (flags: string[] | undefined) => {
const timelineLink = <Link key="/timeline" to="/timeline"> Timeline </Link>;
const links = [];
if (flags && flags.includes('timeline')) {
links.push(timelineLink);
}
return links;
};

const J40Header = () => {
const flags = useFlags();
const intl = useIntl();
const title = intl.formatMessage({
id: '71L0pp',
Expand Down Expand Up @@ -39,7 +47,7 @@ const J40Header = () => {
<div className="usa-navbar">
<Title className={'j40-title'}>{title}</Title>
</div>
<PrimaryNav items={headerLinks}/>
<PrimaryNav items={headerLinks(flags)} />
</div>
</Header>
</>
Expand Down
19 changes: 12 additions & 7 deletions client/src/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ import React, {ReactNode} from 'react';
import * as styles from './layout.module.scss';
import J40Header from './J40Header';
import J40Footer from './J40Footer';
import {URLFlagProvider} from '../contexts/FlagContext';

interface ILayoutProps {
children: ReactNode
children: ReactNode,
location: Location
}

const Layout = ({children}: ILayoutProps) => {
const Layout = ({children, location}: ILayoutProps) => {
return (
<div className={styles.site}>
<J40Header />
<div className={styles.siteContent}>{children}</div>
<J40Footer />
</div>
<URLFlagProvider location={location}>
<div className={styles.site}>
<J40Header />
<div className={styles.siteContent}>{children}</div>
<J40Footer />
</div>
</URLFlagProvider>
);
};

Expand Down
39 changes: 39 additions & 0 deletions client/src/contexts/FlagContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from 'react';
import {render, screen} from '@testing-library/react';
import {URLFlagProvider, useFlags} from './FlagContext';

describe('URL params are parsed and passed to children', () => {
describe('when the URL has a "flags" parameter set', () => {
// We artificially set the URL to localhost?flags=1,2,3
beforeEach(() => {
window.history.pushState({}, 'Test Title', '/?flags=1,2,3');
});
describe('when using useFlags', () => {
beforeEach(() => {
const FlagConsumer = () => {
const flags = useFlags();
return (
<>
<div>{flags.includes('1') ? 'yes1' : 'no1'}</div>
<div>{flags.includes('2') ? 'yes2' : 'no2'}</div>
<div>{flags.includes('3') ? 'yes3' : 'no3'}</div>
<div>{flags.includes('4') ? 'yes4' : 'no4'}</div>
</>
);
};
render(
<URLFlagProvider location={location}>
<FlagConsumer />
</URLFlagProvider>,
);
});

it('gives child components the flag values', async () => {
expect(screen.queryByText('yes1')).toBeInTheDocument();
expect(screen.queryByText('yes2')).toBeInTheDocument();
expect(screen.queryByText('yes3')).toBeInTheDocument();
expect(screen.queryByText('yes4')).not.toBeInTheDocument();
});
});
});
});
56 changes: 56 additions & 0 deletions client/src/contexts/FlagContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from 'react';
import * as queryString from 'query-string';

/**
* FlagContext stores feature flags and passes them to consumers
*/
interface IFlagContext {
/**
* Contains a list of all currently-active flags
*/
flags: string[];
}

const FlagContext = React.createContext<IFlagContext>({flags: []});

/**
* `useFlags` returns all feature flags.
*
* @return {Flags[]} flags All project feature flags
*/
const useFlags = () : string[] => {
const {flags} = React.useContext(FlagContext);
return flags;
};

interface IURLFlagProviderProps {
children: React.ReactNode,
location: Location
}

/**
* `URLFlagProvider` is a provider for FlagContext.
* It is passed the current URL and parses the
* "flags" parameter, assumed to be a comma-separated
* list of currently-active flags.
* @param {URL} location : the current URL object
* @param {ReactNode} children : the children components
* @return {ReactNode} URLFlagProvider component
**/
const URLFlagProvider = ({children, location}: IURLFlagProviderProps) => {
const flagString = queryString.parse(location.search).flags;
let flags: string[] = [];
if (flagString && typeof flagString === 'string') {
flags = (flagString as string).split(',');
}
console.log(JSON.stringify(location), JSON.stringify(flags));

return (
<FlagContext.Provider
value={{flags}}>
{children}
</FlagContext.Provider>
);
};

export {FlagContext, URLFlagProvider, useFlags};
7 changes: 5 additions & 2 deletions client/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import pollutionIcon // @ts-ignore
import washIcon from '/node_modules/uswds/dist/img/usa-icons/wash.svg';
import J40Aside from '../components/J40Aside';

interface IndexPageProps {
location: Location;
};

// markup
const IndexPage = () => {
const IndexPage = ({location}: IndexPageProps) => {
const readMoreList: (any | string)[][] = [
[ecoIcon, 'Clean energy and energy efficiency'],
[busIcon, 'Clean transit'],
Expand All @@ -33,7 +36,7 @@ const IndexPage = () => {
];

return (
<Layout>
<Layout location={location}>
<main id="main-content" role="main">
<div className={'grid-row grid-gap-2'}>
<section className={'grid-container usa-section'}>
Expand Down
8 changes: 6 additions & 2 deletions client/src/pages/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import J40Aside from '../components/J40Aside';
// @ts-ignore
import renewIcon from '/node_modules/uswds/dist/img/usa-icons/autorenew.svg';

const TimelinePage = () => {
return (<Layout>
interface TimelinePageProps {
location: URL;
};

const TimelinePage = ({location}: TimelinePageProps) => {
return (<Layout location={location}>
<main id="main-content" role="main">
<div className={'grid-row grid-gap-2'}>
<section className={'grid-container usa-section'}>
Expand Down
6 changes: 3 additions & 3 deletions client/src/styles/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

.j40-address-readability {
display: inline-block;
line-height: 1.5 !important; // trussworks issue
line-height: 1.5 !important; // trussworks issue
text-align: center;
}

Expand All @@ -68,8 +68,8 @@
&::before {
color: white;
background-color: #00a91c;
content: ''
content: "";
}

border-left-color: #005ea2 !important; // todo: fix
border-left-color: #005ea2 !important; // todo: fix
}

0 comments on commit 7ab14c7

Please sign in to comment.