mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-02-23 01:54:18 -08:00
Adding Simple URL-based feature flags (#117)
* 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
This commit is contained in:
parent
c07a14a8db
commit
7ab14c7f3d
11 changed files with 202 additions and 31 deletions
13
client/.vscode/launch.json
vendored
13
client/.vscode/launch.json
vendored
|
@ -29,6 +29,19 @@
|
||||||
"stopOnEntry": false,
|
"stopOnEntry": false,
|
||||||
"runtimeArgs": ["--nolazy"],
|
"runtimeArgs": ["--nolazy"],
|
||||||
"sourceMaps": false
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
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)
|
- 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
client/package-lock.json
generated
43
client/package-lock.json
generated
|
@ -5904,8 +5904,7 @@
|
||||||
"decode-uri-component": {
|
"decode-uri-component": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
|
||||||
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
|
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"decompress-response": {
|
"decompress-response": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
|
@ -8133,8 +8132,7 @@
|
||||||
"filter-obj": {
|
"filter-obj": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||||
"integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=",
|
"integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"finalhandler": {
|
"finalhandler": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
|
@ -8826,6 +8824,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"query-string": {
|
||||||
|
"version": "6.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.14.1.tgz",
|
||||||
|
"integrity": "sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"decode-uri-component": "^0.2.0",
|
||||||
|
"filter-obj": "^1.1.0",
|
||||||
|
"split-on-first": "^1.0.0",
|
||||||
|
"strict-uri-encode": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source-map": {
|
"source-map": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
||||||
|
@ -15314,6 +15324,18 @@
|
||||||
"requires": {
|
"requires": {
|
||||||
"side-channel": "^1.0.4"
|
"side-channel": "^1.0.4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"query-string": {
|
||||||
|
"version": "6.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.14.1.tgz",
|
||||||
|
"integrity": "sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"decode-uri-component": "^0.2.0",
|
||||||
|
"filter-obj": "^1.1.0",
|
||||||
|
"split-on-first": "^1.0.0",
|
||||||
|
"strict-uri-encode": "^2.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -16212,10 +16234,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"query-string": {
|
"query-string": {
|
||||||
"version": "6.14.1",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.0.tgz",
|
||||||
"integrity": "sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==",
|
"integrity": "sha512-Iy7moLybliR5ZgrK/1R3vjrXq03S13Vz4Rbm5Jg3EFq1LUmQppto0qtXz4vqZ386MSRjZgnTSZ9QC+NZOSd/XA==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"decode-uri-component": "^0.2.0",
|
"decode-uri-component": "^0.2.0",
|
||||||
"filter-obj": "^1.1.0",
|
"filter-obj": "^1.1.0",
|
||||||
|
@ -18433,8 +18454,7 @@
|
||||||
"split-on-first": {
|
"split-on-first": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"split-string": {
|
"split-string": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
@ -18623,8 +18643,7 @@
|
||||||
"strict-uri-encode": {
|
"strict-uri-encode": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||||
"integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=",
|
"integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"string-env-interpolation": {
|
"string-env-interpolation": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
|
|
@ -68,6 +68,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@trussworks/react-uswds": "github:nathillardusds/react-uswds#nathillardusds/ssr",
|
"@trussworks/react-uswds": "github:nathillardusds/react-uswds#nathillardusds/ssr",
|
||||||
|
"query-string": "^7.0.0",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
|
|
|
@ -4,13 +4,21 @@ import {GovBanner,
|
||||||
Title,
|
Title,
|
||||||
PrimaryNav,
|
PrimaryNav,
|
||||||
SiteAlert} from '@trussworks/react-uswds';
|
SiteAlert} from '@trussworks/react-uswds';
|
||||||
import {useIntl} from 'gatsby-plugin-intl';
|
import {useIntl, Link} from 'gatsby-plugin-intl';
|
||||||
import {Helmet} from 'react-helmet';
|
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 J40Header = () => {
|
||||||
|
const flags = useFlags();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const title = intl.formatMessage({
|
const title = intl.formatMessage({
|
||||||
id: '71L0pp',
|
id: '71L0pp',
|
||||||
|
@ -39,7 +47,7 @@ const J40Header = () => {
|
||||||
<div className="usa-navbar">
|
<div className="usa-navbar">
|
||||||
<Title className={'j40-title'}>{title}</Title>
|
<Title className={'j40-title'}>{title}</Title>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryNav items={headerLinks}/>
|
<PrimaryNav items={headerLinks(flags)} />
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -2,17 +2,22 @@ import React, {ReactNode} from 'react';
|
||||||
import * as styles from './layout.module.scss';
|
import * as styles from './layout.module.scss';
|
||||||
import J40Header from './J40Header';
|
import J40Header from './J40Header';
|
||||||
import J40Footer from './J40Footer';
|
import J40Footer from './J40Footer';
|
||||||
|
import {URLFlagProvider} from '../contexts/FlagContext';
|
||||||
|
|
||||||
interface ILayoutProps {
|
interface ILayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode,
|
||||||
|
location: Location
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout = ({children}: ILayoutProps) => {
|
const Layout = ({children, location}: ILayoutProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.site}>
|
<URLFlagProvider location={location}>
|
||||||
<J40Header />
|
<div className={styles.site}>
|
||||||
<div className={styles.siteContent}>{children}</div>
|
<J40Header />
|
||||||
<J40Footer />
|
<div className={styles.siteContent}>{children}</div>
|
||||||
</div>
|
<J40Footer />
|
||||||
|
</div>
|
||||||
|
</URLFlagProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
39
client/src/contexts/FlagContext.test.tsx
Normal file
39
client/src/contexts/FlagContext.test.tsx
Normal file
|
@ -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
client/src/contexts/FlagContext.tsx
Normal file
56
client/src/contexts/FlagContext.tsx
Normal file
|
@ -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};
|
|
@ -20,9 +20,12 @@ import pollutionIcon // @ts-ignore
|
||||||
import washIcon from '/node_modules/uswds/dist/img/usa-icons/wash.svg';
|
import washIcon from '/node_modules/uswds/dist/img/usa-icons/wash.svg';
|
||||||
import J40Aside from '../components/J40Aside';
|
import J40Aside from '../components/J40Aside';
|
||||||
|
|
||||||
|
interface IndexPageProps {
|
||||||
|
location: Location;
|
||||||
|
};
|
||||||
|
|
||||||
// markup
|
// markup
|
||||||
const IndexPage = () => {
|
const IndexPage = ({location}: IndexPageProps) => {
|
||||||
const readMoreList: (any | string)[][] = [
|
const readMoreList: (any | string)[][] = [
|
||||||
[ecoIcon, 'Clean energy and energy efficiency'],
|
[ecoIcon, 'Clean energy and energy efficiency'],
|
||||||
[busIcon, 'Clean transit'],
|
[busIcon, 'Clean transit'],
|
||||||
|
@ -33,7 +36,7 @@ const IndexPage = () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout location={location}>
|
||||||
<main id="main-content" role="main">
|
<main id="main-content" role="main">
|
||||||
<div className={'grid-row grid-gap-2'}>
|
<div className={'grid-row grid-gap-2'}>
|
||||||
<section className={'grid-container usa-section'}>
|
<section className={'grid-container usa-section'}>
|
||||||
|
|
|
@ -5,8 +5,12 @@ import J40Aside from '../components/J40Aside';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import renewIcon from '/node_modules/uswds/dist/img/usa-icons/autorenew.svg';
|
import renewIcon from '/node_modules/uswds/dist/img/usa-icons/autorenew.svg';
|
||||||
|
|
||||||
const TimelinePage = () => {
|
interface TimelinePageProps {
|
||||||
return (<Layout>
|
location: URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimelinePage = ({location}: TimelinePageProps) => {
|
||||||
|
return (<Layout location={location}>
|
||||||
<main id="main-content" role="main">
|
<main id="main-content" role="main">
|
||||||
<div className={'grid-row grid-gap-2'}>
|
<div className={'grid-row grid-gap-2'}>
|
||||||
<section className={'grid-container usa-section'}>
|
<section className={'grid-container usa-section'}>
|
||||||
|
|
|
@ -59,7 +59,7 @@
|
||||||
|
|
||||||
.j40-address-readability {
|
.j40-address-readability {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
line-height: 1.5 !important; // trussworks issue
|
line-height: 1.5 !important; // trussworks issue
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,8 +68,8 @@
|
||||||
&::before {
|
&::before {
|
||||||
color: white;
|
color: white;
|
||||||
background-color: #00a91c;
|
background-color: #00a91c;
|
||||||
content: '✓'
|
content: "✓";
|
||||||
}
|
}
|
||||||
|
|
||||||
border-left-color: #005ea2 !important; // todo: fix
|
border-left-color: #005ea2 !important; // todo: fix
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue