mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-08-08 15:54:19 -07: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
|
@ -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',
|
||||
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
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 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'],
|
||||
|
@ -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'}>
|
||||
|
|
|
@ -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'}>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue