diff --git a/client/.vscode/launch.json b/client/.vscode/launch.json index 24adb9d2..5936d0ab 100644 --- a/client/.vscode/launch.json +++ b/client/.vscode/launch.json @@ -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 } ] } diff --git a/client/README.md b/client/README.md index 72288093..db8fa46f 100644 --- a/client/README.md +++ b/client/README.md @@ -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! diff --git a/client/package-lock.json b/client/package-lock.json index 7e082298..d641efbe 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5904,8 +5904,7 @@ "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, "decompress-response": { "version": "3.3.0", @@ -8133,8 +8132,7 @@ "filter-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=", - "dev": true + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" }, "finalhandler": { "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": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", @@ -15314,6 +15324,18 @@ "requires": { "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 }, "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, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.0.tgz", + "integrity": "sha512-Iy7moLybliR5ZgrK/1R3vjrXq03S13Vz4Rbm5Jg3EFq1LUmQppto0qtXz4vqZ386MSRjZgnTSZ9QC+NZOSd/XA==", "requires": { "decode-uri-component": "^0.2.0", "filter-obj": "^1.1.0", @@ -18433,8 +18454,7 @@ "split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", - "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", - "dev": true + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" }, "split-string": { "version": "3.1.0", @@ -18623,8 +18643,7 @@ "strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", - "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=", - "dev": true + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" }, "string-env-interpolation": { "version": "1.0.1", diff --git a/client/package.json b/client/package.json index d336b695..a4202910 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/J40Header.tsx b/client/src/components/J40Header.tsx index 4f877377..e7456aa6 100644 --- a/client/src/components/J40Header.tsx +++ b/client/src/components/J40Header.tsx @@ -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 = Timeline ; + 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 = () => {
{title}
- + diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index cd3f2d44..ba83a965 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -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 ( -
- -
{children}
- -
+ +
+ +
{children}
+ +
+
); }; diff --git a/client/src/contexts/FlagContext.test.tsx b/client/src/contexts/FlagContext.test.tsx new file mode 100644 index 00000000..89c6a335 --- /dev/null +++ b/client/src/contexts/FlagContext.test.tsx @@ -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 ( + <> +
{flags.includes('1') ? 'yes1' : 'no1'}
+
{flags.includes('2') ? 'yes2' : 'no2'}
+
{flags.includes('3') ? 'yes3' : 'no3'}
+
{flags.includes('4') ? 'yes4' : 'no4'}
+ + ); + }; + render( + + + , + ); + }); + + 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(); + }); + }); + }); +}); diff --git a/client/src/contexts/FlagContext.tsx b/client/src/contexts/FlagContext.tsx new file mode 100644 index 00000000..0c710e63 --- /dev/null +++ b/client/src/contexts/FlagContext.tsx @@ -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({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 ( + + {children} + + ); +}; + +export {FlagContext, URLFlagProvider, useFlags}; diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 6637e50b..acc4981d 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -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 ( - +
diff --git a/client/src/pages/timeline.tsx b/client/src/pages/timeline.tsx index 987e5cd3..564e8f62 100644 --- a/client/src/pages/timeline.tsx +++ b/client/src/pages/timeline.tsx @@ -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 ( +interface TimelinePageProps { + location: URL; +}; + +const TimelinePage = ({location}: TimelinePageProps) => { + return (
diff --git a/client/src/styles/global.scss b/client/src/styles/global.scss index c7897e98..b729b300 100644 --- a/client/src/styles/global.scss +++ b/client/src/styles/global.scss @@ -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 }