Skip to content

Commit

Permalink
[Issue #3249] Add auth ui feature flag and refactor feature flag syst…
Browse files Browse the repository at this point in the history
…em for client side use (#3374)

* rewrites the FeatureFlagManager and useFeatureFlag hook to allow syncing flags between server and client using cookies
* adds the `authOn` feature flag in frontend code and terraform
* refactors the `environments` setup a bit to more easily expose feature flags
* splits functionality that does not benefit from being held in the FeatureFlagsManager class into a helper file
* moves feature flag manager file into a nested directory
  • Loading branch information
doug-s-nava committed Jan 7, 2025
1 parent f7c1248 commit f73dffe
Show file tree
Hide file tree
Showing 22 changed files with 1,019 additions and 977 deletions.
64 changes: 29 additions & 35 deletions frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,9 @@ import Loading from "src/components/Loading";
* View for managing feature flags
*/
export default function FeatureFlagsTable() {
const { featureFlagsManager, mounted, setFeatureFlag } = useFeatureFlags();
const { setFeatureFlag, featureFlags } = useFeatureFlags();
const { user, isLoading, error } = useUser();

if (!mounted) {
return null;
}

if (isLoading) {
return <Loading />;
}
Expand Down Expand Up @@ -47,37 +43,35 @@ export default function FeatureFlagsTable() {
</tr>
</thead>
<tbody>
{Object.entries(featureFlagsManager.featureFlags).map(
([featureName, enabled]) => (
<tr key={featureName}>
<td
data-testid={`${featureName}-status`}
style={{ background: enabled ? "#81cc81" : "#fc6a6a" }}
{Object.entries(featureFlags).map(([featureName, enabled]) => (
<tr key={featureName}>
<td
data-testid={`${featureName}-status`}
style={{ background: enabled ? "#81cc81" : "#fc6a6a" }}
>
{enabled ? "Enabled" : "Disabled"}
</td>
<th scope="row">{featureName}</th>
<td>
<Button
data-testid={`enable-${featureName}`}
disabled={!!enabled}
onClick={() => setFeatureFlag(featureName, true)}
type="button"
>
Enable
</Button>
<Button
data-testid={`disable-${featureName}`}
disabled={!enabled}
onClick={() => setFeatureFlag(featureName, false)}
type="button"
>
{enabled ? "Enabled" : "Disabled"}
</td>
<th scope="row">{featureName}</th>
<td>
<Button
data-testid={`enable-${featureName}`}
disabled={enabled}
onClick={() => setFeatureFlag(featureName, true)}
type="button"
>
Enable
</Button>
<Button
data-testid={`disable-${featureName}`}
disabled={!enabled}
onClick={() => setFeatureFlag(featureName, false)}
type="button"
>
Disable
</Button>
</td>
</tr>
),
)}
Disable
</Button>
</td>
</tr>
))}
</tbody>
</Table>
</>
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import clsx from "clsx";
import { useFeatureFlags } from "src/hooks/useFeatureFlags";
import { assetPath } from "src/utils/assetPath";

import { useTranslations } from "next-intl";
Expand Down Expand Up @@ -170,6 +171,9 @@ const Header = ({ logoPath, locale }: Props) => {
};
}, [isMobileNavExpanded, closeMenuOnEscape]);

const { checkFeatureFlag } = useFeatureFlags();
const showLoginLink = checkFeatureFlag("authOn");

const language = locale && locale.match("/^es/") ? "spanish" : "english";

const handleMobileNavToggle = () => {
Expand Down Expand Up @@ -218,11 +222,13 @@ const Header = ({ logoPath, locale }: Props) => {
className="usa-menu-btn"
/>
</div>
<div className="usa-nav__primary margin-top-0 margin-bottom-1 desktop:margin-bottom-5px text-no-wrap desktop:order-last margin-left-auto">
<div className="usa-nav__primary-item border-0">
<LoginLink navLoginLinkText={t("nav_link_login")} />
{!!showLoginLink && (
<div className="usa-nav__primary margin-top-0 margin-bottom-1 desktop:margin-bottom-5px text-no-wrap desktop:order-last margin-left-auto">
<div className="usa-nav__primary-item border-0">
<LoginLink navLoginLinkText={t("nav_link_login")} />
</div>
</div>
</div>
)}
<NavLinks
mobileExpanded={isMobileNavExpanded}
onToggleMobileNav={handleMobileNavToggle}
Expand Down
16 changes: 2 additions & 14 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import pick from "lodash/pick";

import {
NextIntlClientProvider,
useMessages,
useTranslations,
} from "next-intl";
import { useTranslations } from "next-intl";
import { setRequestLocale } from "next-intl/server";

import Footer from "./Footer";
Expand All @@ -20,20 +14,14 @@ export default function Layout({ children, locale }: Props) {
setRequestLocale(locale);

const t = useTranslations();
const messages = useMessages();

return (
// Stick the footer to the bottom of the page
<div className="display-flex flex-column minh-viewport">
<a className="usa-skipnav z-top" href="#main-content">
{t("Layout.skip_to_main")}
</a>
<NextIntlClientProvider
locale={locale}
messages={pick(messages, "Header")}
>
<Header locale={locale} />
</NextIntlClientProvider>
<Header locale={locale} />
<main id="main-content" className="border-top-0">
{children}
</main>
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/constants/defaultFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type FeatureFlags = { [name: string]: boolean };

export const defaultFeatureFlags: FeatureFlags = {
// Kill switches for search and opportunity pages, will show maintenance page when turned on
searchOff: false,
opportunityOff: false,
// should we show a sign in button in the header?
authOn: false,
};
33 changes: 20 additions & 13 deletions frontend/src/constants/environments.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
import { stringToBoolean } from "src/utils/generalUtils";

const {
NEXT_PUBLIC_BASE_PATH,
USE_SEARCH_MOCK_DATA = "",
USE_SEARCH_MOCK_DATA,
SENDY_API_URL,
SENDY_API_KEY,
SENDY_LIST_ID,
API_URL,
API_AUTH_TOKEN = "",
API_AUTH_TOKEN,
NEXT_BUILD,
SESSION_SECRET,
NEXT_PUBLIC_BASE_URL,
FEATURE_SEARCH_OFF = "false",
FEATURE_OPPORTUNITY_OFF = "false",
NEXT_BUILD = "false",
ENVIRONMENT = "dev",
SESSION_SECRET = "",
FEATURE_SEARCH_OFF,
FEATURE_OPPORTUNITY_OFF,
FEATURE_AUTH_ON,
AUTH_LOGIN_URL,
} = process.env;

export const featureFlags = {
opportunityOff: stringToBoolean(FEATURE_OPPORTUNITY_OFF),
searchOff: stringToBoolean(FEATURE_SEARCH_OFF),
authOn: stringToBoolean(FEATURE_AUTH_ON),
};

// home for all interpreted server side environment variables
export const environment: { [key: string]: string } = {
LEGACY_HOST:
ENVIRONMENT === "prod" ? "https://grants.gov" : "https://test.grants.gov",
NEXT_PUBLIC_BASE_PATH: NEXT_PUBLIC_BASE_PATH ?? "",
USE_SEARCH_MOCK_DATA,
USE_SEARCH_MOCK_DATA: USE_SEARCH_MOCK_DATA || "",
SENDY_API_URL: SENDY_API_URL || "",
SENDY_API_KEY: SENDY_API_KEY || "",
SENDY_LIST_ID: SENDY_LIST_ID || "",
API_URL: API_URL || "",
API_AUTH_TOKEN,
AUTH_LOGIN_URL: AUTH_LOGIN_URL || "",
NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000",
API_AUTH_TOKEN: API_AUTH_TOKEN || "",
GOOGLE_TAG_MANAGER_ID: "GTM-MV57HMHS",
FEATURE_OPPORTUNITY_OFF,
FEATURE_SEARCH_OFF,
NEXT_BUILD,
ENVIRONMENT,
SESSION_SECRET,
NEXT_BUILD: NEXT_BUILD || "false",
SESSION_SECRET: SESSION_SECRET || "",
NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000",
};
8 changes: 0 additions & 8 deletions frontend/src/constants/featureFlags.ts

This file was deleted.

5 changes: 2 additions & 3 deletions frontend/src/hoc/withFeatureFlag.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { environment } from "src/constants/environments";
import { FeatureFlagsManager } from "src/services/FeatureFlagManager";
import { featureFlagsManager } from "src/services/featureFlags/FeatureFlagManager";
import { WithFeatureFlagProps } from "src/types/uiTypes";

import { cookies } from "next/headers";
Expand All @@ -25,11 +25,10 @@ const withFeatureFlag = <P, R>(
) => {
const searchParams = props.searchParams || {};
const ComponentWithFeatureFlag = (props: P & WithFeatureFlagProps) => {
const featureFlagsManager = new FeatureFlagsManager(cookies());

if (
featureFlagsManager.isFeatureEnabled(
featureFlagName,
cookies(),
props.searchParams,
)
) {
Expand Down
94 changes: 58 additions & 36 deletions frontend/src/hooks/useFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,70 @@
"use client";

import Cookies from "js-cookie";
import { FeatureFlagsManager } from "src/services/FeatureFlagManager";
import { isBoolean } from "lodash";
import {
defaultFeatureFlags,
FeatureFlags,
} from "src/constants/defaultFeatureFlags";
import {
FEATURE_FLAGS_KEY,
getCookieExpiration,
} from "src/services/featureFlags/featureFlagHelpers";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";

/**
* React hook for reading and managing feature flags in client-side code.
*
* ```
* function MyComponent() {
* const {
* featureFlagsManager, // An instance of FeatureFlagsManager
* mounted, // Useful for hydration
* setFeatureFlag, // Proxy for featureFlagsManager.setFeatureFlagCookie that handles updating state
* } = useFeatureFlags()
*
* if (featureFlagsManager.isFeatureEnabled("someFeatureFlag")) {
* // Do something
* }
*
* if (!mounted) {
* // To allow hydration
* return null
* }
* Allows client components to access feature flags by
* - setting the cookie
* - reading the cookie
*
* return (
* ...
* )
* }
* ```
*/
export function useFeatureFlags() {
const [featureFlagsManager, setFeatureFlagsManager] = useState(
new FeatureFlagsManager(Cookies),
);
const [mounted, setMounted] = useState(false);
export function useFeatureFlags(): {
setFeatureFlag: (flagName: string, value: boolean) => void;
checkFeatureFlag: (flagName: string) => boolean;
featureFlags: FeatureFlags;
} {
const [featureFlags, setFeatureFlags] =
useState<FeatureFlags>(defaultFeatureFlags);

// a workaround, as setting this in default state value results in hydration error
useEffect(() => {
setMounted(true);
const flagsFromCookie = JSON.parse(
Cookies.get(FEATURE_FLAGS_KEY) || "{}",
) as FeatureFlags;
setFeatureFlags(flagsFromCookie);
}, []);

function setFeatureFlag(name: string, value: boolean) {
featureFlagsManager.setFeatureFlagCookie(name, value);
setFeatureFlagsManager(new FeatureFlagsManager(Cookies));
}
// Note that values set in cookies will be persistent per browser session unless explicitly overwritten
const setFeatureFlag = useCallback(
(flagName: string, value: boolean) => {
const newFlags = {
...featureFlags,
[flagName]: value,
};
setFeatureFlags(newFlags);
Cookies.set(FEATURE_FLAGS_KEY, JSON.stringify(newFlags), {
expires: getCookieExpiration(),
});
},
[featureFlags, setFeatureFlags],
);

const checkFeatureFlag = useCallback(
(flagName: string): boolean => {
const value = featureFlags[flagName];
if (!isBoolean(value)) {
console.error("Unknown or misconfigured feature flag: ", flagName);
return false;
}
return value;
},
[featureFlags],
);

return { featureFlagsManager, mounted, setFeatureFlag };
return {
setFeatureFlag,
checkFeatureFlag,
featureFlags,
};
}
13 changes: 4 additions & 9 deletions frontend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
* modifying the request or response headers, or responding directly.
* @see https://nextjs.org/docs/app/building-your-application/routing/middleware
*/
import { defaultLocale, locales } from "src/i18n/config";
import { featureFlagsManager } from "src/services/featureFlags/FeatureFlagManager";

import createIntlMiddleware from "next-intl/middleware";
import { NextRequest, NextResponse } from "next/server";

import { defaultLocale, locales } from "./i18n/config";
import { FeatureFlagsManager } from "./services/FeatureFlagManager";

export const config = {
matcher: [
/*
Expand Down Expand Up @@ -40,10 +40,5 @@ const i18nMiddleware = createIntlMiddleware({
});

export default function middleware(request: NextRequest): NextResponse {
let response = i18nMiddleware(request);

const featureFlagsManager = new FeatureFlagsManager(request.cookies);
response = featureFlagsManager.middleware(request, response);

return response;
return featureFlagsManager.middleware(request, i18nMiddleware(request));
}
Loading

0 comments on commit f73dffe

Please sign in to comment.