Skip to content

Commit

Permalink
[Issue #2695] opportunity and search toggle with feature flag env vars (
Browse files Browse the repository at this point in the history
#3115)

* adds maintenance page
* adds "kill switch" flags that can be used to direct the search and opportunity pages to
* adds ability to set flags with environment variables
  • Loading branch information
doug-s-nava authored Dec 11, 2024
1 parent b357de4 commit 7a7b8a8
Show file tree
Hide file tree
Showing 19 changed files with 377 additions and 111 deletions.
3 changes: 0 additions & 3 deletions frontend/.pa11yci-desktop.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@
"ignoreHTTPSErrors": true,
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
},
"headers": {
"Cookie": "_ff={%22showSearchV0%22:true}"
},
"actions": [
"wait for element #main-content to be visible",
"screen capture screenshots-output/desktop-main-view.png"
Expand Down
3 changes: 0 additions & 3 deletions frontend/.pa11yci-mobile.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@
"ignoreHTTPSErrors": true,
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
},
"headers": {
"Cookie": "_ff={%22showSearchV0%22:true}"
},
"viewport": {
"width": 390,
"height": 844,
Expand Down
4 changes: 4 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ COPY src ./src

ENV NEXT_TELEMETRY_DISABLED 1

# let the application know that it is being built
ENV NEXT_BUILD true

# Skip lint because it should have happened in the CI already
RUN npm run build -- --no-lint

Expand Down Expand Up @@ -110,6 +113,7 @@ ENV SENDY_LIST_ID=${SENDY_LIST_ID}

ENV NEXT_TELEMETRY_DISABLED 1
ENV PORT 3000
ENV NEXT_BUILD false

EXPOSE 3000

Expand Down
36 changes: 36 additions & 0 deletions frontend/src/app/[locale]/maintenance/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useTranslations } from "next-intl";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { GridContainer } from "@trussworks/react-uswds";

export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale });
return {
title: t("Maintenance.pageTitle"),
description: t("Maintenance.heading"),
};
}

export default function Maintenance({
params: { locale },
}: {
params: { locale: string };
}) {
setRequestLocale(locale);
const t = useTranslations("Maintenance");

const body = t.rich("body", {
LinkToGrants: (content) => <a href="https://www.grants.gov">{content}</a>,
});

return (
<GridContainer className="padding-y-1 tablet:padding-y-3 desktop-lg:padding-y-6 padding-x-5 tablet:padding-x-7 desktop-lg:padding-x-10 text-center">
<h2 className="margin-bottom-0">{t("heading")}</h2>
<p>{body}</p>
<p>{t("signOff")}</p>
</GridContainer>
);
}
21 changes: 15 additions & 6 deletions frontend/src/app/[locale]/opportunity/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import NotFound from "src/app/[locale]/not-found";
import { fetchOpportunity } from "src/app/api/fetchers";
import { OPPORTUNITY_CRUMBS } from "src/constants/breadcrumbs";
import { ApiRequestError, parseErrorStatus } from "src/errors";
import withFeatureFlag from "src/hoc/search/withFeatureFlag";
import { Opportunity } from "src/types/opportunity/opportunityResponseTypes";
import { WithFeatureFlagProps } from "src/types/uiTypes";

import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { GridContainer } from "@trussworks/react-uswds";

import BetaAlert from "src/components/BetaAlert";
Expand All @@ -20,7 +22,12 @@ import OpportunityIntro from "src/components/opportunity/OpportunityIntro";
import OpportunityLink from "src/components/opportunity/OpportunityLink";
import OpportunityStatusWidget from "src/components/opportunity/OpportunityStatusWidget";

type OpportunityListingProps = {
params: { id: string };
} & WithFeatureFlagProps;

export const revalidate = 600; // invalidate ten minutes
export const dynamic = "force-dynamic";

export async function generateMetadata({ params }: { params: { id: string } }) {
const t = await getTranslations({ locale: "en" });
Expand Down Expand Up @@ -83,11 +90,7 @@ function emptySummary() {
};
}

export default async function OpportunityListing({
params,
}: {
params: { id: string };
}) {
async function OpportunityListing({ params }: OpportunityListingProps) {
const idForParsing = Number(params.id);
const breadcrumbs = Object.assign([], OPPORTUNITY_CRUMBS);
// Opportunity id needs to be a number greater than 1
Expand Down Expand Up @@ -148,3 +151,9 @@ export default async function OpportunityListing({
</div>
);
}

export default withFeatureFlag<OpportunityListingProps, never>(
OpportunityListing,
"opportunityOff",
() => redirect("/maintenance"),
);
14 changes: 11 additions & 3 deletions frontend/src/app/[locale]/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearch

import { useTranslations } from "next-intl";
import { getTranslations, unstable_setRequestLocale } from "next-intl/server";
import { redirect } from "next/navigation";

import ContentDisplayToggle from "src/components/ContentDisplayToggle";
import SearchAnalytics from "src/components/search/SearchAnalytics";
Expand All @@ -22,7 +23,10 @@ export async function generateMetadata() {
};
return meta;
}
function Search({ searchParams }: { searchParams: SearchParamsTypes }) {

type SearchPageProps = { searchParams: SearchParamsTypes };

function Search({ searchParams }: SearchPageProps) {
unstable_setRequestLocale("en");
const t = useTranslations("Search");

Expand Down Expand Up @@ -73,5 +77,9 @@ function Search({ searchParams }: { searchParams: SearchParamsTypes }) {
);
}

// Exports page behind a feature flag
export default withFeatureFlag(Search, "showSearchV0");
// Exports page behind both feature flags
export default withFeatureFlag<SearchPageProps, never>(
Search,
"searchOff",
() => redirect("/maintenance"),
);
6 changes: 6 additions & 0 deletions frontend/src/constants/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const {
API_URL,
API_AUTH_TOKEN = "",
NEXT_PUBLIC_BASE_URL,
FEATURE_SEARCH_OFF = "false",
FEATURE_OPPORTUNITY_OFF = "false",
NEXT_BUILD = "false",
} = process.env;

// home for all interpreted server side environment variables
Expand All @@ -25,4 +28,7 @@ export const environment: { [key: string]: string } = {
API_AUTH_TOKEN,
NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000",
GOOGLE_TAG_MANAGER_ID: "GTM-MV57HMHS",
FEATURE_OPPORTUNITY_OFF,
FEATURE_SEARCH_OFF,
NEXT_BUILD,
};
6 changes: 3 additions & 3 deletions frontend/src/constants/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FeatureFlags } from "src/services/FeatureFlagManager";

// Feature flags should default to false
export const featureFlags: FeatureFlags = {
// This is for showing the search page as it is being developed and user tested
// This should be removed when the search page goes live, before May 2024
showSearchV0: true,
// Kill switches for search and opportunity pages, will show maintenance page when turned on
searchOff: false,
opportunityOff: false,
};
48 changes: 32 additions & 16 deletions frontend/src/hoc/search/withFeatureFlag.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
import { environment } from "src/constants/environments";
import { FeatureFlagsManager } from "src/services/FeatureFlagManager";
import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
import { WithFeatureFlagProps } from "src/types/uiTypes";

import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import React, { ComponentType } from "react";

type WithFeatureFlagProps = {
searchParams: ServerSideSearchParams;
};

const withFeatureFlag = <P extends object>(
// since this relies on search params coming in as a prop, it can only be used reliably on a top level page component
// for other components we'll need a different implementation, likely one that delivers particular props to the wrapped component
// that method is not easily implemented with top level page components, as their props are laregely dictated by the Next system
const withFeatureFlag = <P, R>(
WrappedComponent: ComponentType<P>,
featureFlagName: string,
onEnabled: () => R,
) => {
const ComponentWithFeatureFlag: React.FC<P & WithFeatureFlagProps> = (
props,
// if we're in the middle of a build, that means this is an ssg rendering pass.
// in that case we can skip this whole feature flag business and move on with our lives
if (environment.NEXT_BUILD === "true") {
return WrappedComponent;
}

// top level component to grab search params from the top level page props
const ComponentWithFeatureFlagAndSearchParams = (
props: P & WithFeatureFlagProps,
) => {
const ffManager = new FeatureFlagsManager(cookies());
const { searchParams } = props;
const searchParams = props.searchParams || {};
const ComponentWithFeatureFlag = (props: P & WithFeatureFlagProps) => {
const featureFlagsManager = new FeatureFlagsManager(cookies());

if (ffManager.isFeatureDisabled(featureFlagName, searchParams)) {
return notFound();
}
if (
featureFlagsManager.isFeatureEnabled(
featureFlagName,
props.searchParams,
)
) {
onEnabled();
return;
}

return <WrappedComponent {...(props as P)} />;
return <WrappedComponent {...props} />;
};
return <ComponentWithFeatureFlag {...props} searchParams={searchParams} />;
};

return ComponentWithFeatureFlag;
return ComponentWithFeatureFlagAndSearchParams;
};

export default withFeatureFlag;
4 changes: 2 additions & 2 deletions frontend/src/hooks/useFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useEffect, useState } from "react";
* const {
* featureFlagsManager, // An instance of FeatureFlagsManager
* mounted, // Useful for hydration
* setFeatureFlag, // Proxy for featureFlagsManager.setFeatureFlag that handles updating state
* setFeatureFlag, // Proxy for featureFlagsManager.setFeatureFlagCookie that handles updating state
* } = useFeatureFlags()
*
* if (featureFlagsManager.isFeatureEnabled("someFeatureFlag")) {
Expand Down Expand Up @@ -40,7 +40,7 @@ export function useFeatureFlags() {
}, []);

function setFeatureFlag(name: string, value: boolean) {
featureFlagsManager.setFeatureFlag(name, value);
featureFlagsManager.setFeatureFlagCookie(name, value);
setFeatureFlagsManager(new FeatureFlagsManager(Cookies));
}

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/messages/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,4 +542,10 @@ export const messages = {
},
generic_error_cta: "Please try your search again.",
},
Maintenance: {
heading: "Simpler.Grants.gov Is Currently Undergoing Maintenance",
body: "Our team is working to improve the site, and we’ll have it back up as soon as possible. In the meantime, please visit <LinkToGrants>www.Grants.gov</LinkToGrants> to search for funding opportunities and manage your applications.",
signOff: "Thank you for your patience.",
pageTitle: "Simpler.Grants.gov - Maintenance",
},
};
Loading

0 comments on commit 7a7b8a8

Please sign in to comment.