From 15f912e2003ada16bf9db328ce33614006465a8f Mon Sep 17 00:00:00 2001 From: Keng Ye Date: Wed, 8 Feb 2023 18:37:38 +0800 Subject: [PATCH 1/3] feat: add feature flag provider --- .../src/contexts/FeatureFlagProvider.tsx | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx diff --git a/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx b/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx new file mode 100644 index 0000000..4c5705c --- /dev/null +++ b/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx @@ -0,0 +1,184 @@ +import { + getEnvironment, + FeatureFlag, + FeatureFlagID, + Platform, +} from "@waveshq/walletkit-core"; +import React, { + createContext, + ReactElement, + useContext, + useEffect, + useState, + PropsWithChildren, +} from "react"; +import { satisfies } from "semver"; +import { useNetworkContext } from "./NetworkContext"; +import { useServiceProviderContext } from "./StoreServiceProvider"; +import { useGetFeatureFlagsQuery, usePrefetch } from "../store"; +import { BaseLogger } from "./logger"; + +const MAX_RETRY = 3; +export interface FeatureFlagContextI { + featureFlags: FeatureFlag[]; + enabledFeatures: FeatureFlagID[]; + updateEnabledFeatures: (features: FeatureFlagID[]) => void; + isFeatureAvailable: (featureId: FeatureFlagID) => boolean; + isBetaFeature: (featureId: FeatureFlagID) => boolean; + hasBetaFeatures: boolean; +} + +const FeatureFlagContext = createContext(undefined as any); + +export function useFeatureFlagContext(): FeatureFlagContextI { + return useContext(FeatureFlagContext); +} + +export interface FeatureFlagProviderProps extends PropsWithChildren<{}> { + api: { + get: () => Promise; + set: (features: FeatureFlagID[]) => Promise; + }; + logger: BaseLogger; + releaseChannel: string; + platformOS: Platform; + nativeApplicationVersion: string | null; +} + +export function FeatureFlagProvider( + props: FeatureFlagProviderProps +): JSX.Element | null { + const { logger, api, releaseChannel, platformOS, nativeApplicationVersion } = + props; + const { network } = useNetworkContext(); + const { url, isCustomUrl } = useServiceProviderContext(); + const { + data: featureFlags = [], + isLoading, + isError, + refetch, + } = useGetFeatureFlagsQuery(`${network}.${url}`); + + const prefetchPage = usePrefetch("getFeatureFlags"); + const appVersion = nativeApplicationVersion ?? "0.0.0"; + const [enabledFeatures, setEnabledFeatures] = useState([]); + const [retries, setRetries] = useState(0); + + useEffect(() => { + if (isError && retries < MAX_RETRY) { + setTimeout(() => { + prefetchPage({}); + setRetries(retries + 1); + }, 10000); + } else if (!isError) { + prefetchPage({}); + } + }, [isError]); + + useEffect(() => { + refetch(); + }, [network]); + + function isBetaFeature(featureId: FeatureFlagID): boolean { + return featureFlags.some( + (flag: FeatureFlag) => + satisfies(appVersion, flag.version) && + flag.networks?.includes(network) && + flag.id === featureId && + flag.stage === "beta" + ); + } + + function isFeatureAvailable(featureId: FeatureFlagID): boolean { + return featureFlags.some((flag: FeatureFlag) => { + if ( + flag.networks?.includes(network) && + flag.app?.includes("MOBILE_LW") && + flag.platforms?.includes(platformOS) + ) { + if (platformOS === "web") { + return flag.id === featureId && checkFeatureStage(flag); + } + return ( + satisfies(appVersion, flag.version) && + flag.id === featureId && + checkFeatureStage(flag) + ); + } + return false; + }); + } + + function checkFeatureStage(feature: FeatureFlag): boolean { + switch (feature.stage) { + case "alpha": + return getEnvironment(releaseChannel).debug; + case "beta": + return enabledFeatures.includes(feature.id); + case "public": + return true; + default: + return false; + } + } + + const updateEnabledFeatures = async ( + flags: FeatureFlagID[] + ): Promise => { + setEnabledFeatures(flags); + await api.set(flags); + }; + + useEffect(() => { + api + .get() + .then((features) => { + setEnabledFeatures(features); + }) + .catch((err) => logger.error(err)); + }, []); + + /* + If service provider === custom, we keep showing the app regardless if feature flags loaded to ensure app won't be stuck on white screen + Note: return null === app will be stuck at white screen until the feature flags API are applied + */ + if (isLoading && !isCustomUrl) { + return null; + } + + const context: FeatureFlagContextI = { + featureFlags, + enabledFeatures, + updateEnabledFeatures, + isFeatureAvailable, + isBetaFeature, + hasBetaFeatures: featureFlags.some( + (flag) => + satisfies(appVersion, flag.version) && + flag.networks?.includes(network) && + flag.platforms?.includes(platformOS) && + flag.stage === "beta" + ), + }; + + if (isError && !isLoading && retries < MAX_RETRY) { + return <>; + } + + return ( + + {props.children} + + ); +} + +export function FeatureGate({ + children, + feature, +}: { + children: ReactElement; + feature: FeatureFlagID; +}): JSX.Element | null { + const { isFeatureAvailable } = useFeatureFlagContext(); + return isFeatureAvailable(feature) ? children : null; +} From 1463fae975d68d834ec6d7358409ffdfd9751dfd Mon Sep 17 00:00:00 2001 From: Keng Ye Date: Wed, 8 Feb 2023 19:06:15 +0800 Subject: [PATCH 2/3] chore: linting --- .../src/contexts/FeatureFlagProvider.tsx | 82 +++++++++++-------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx b/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx index 4c5705c..99b6746 100644 --- a/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx +++ b/packages/walletkit-ui/src/contexts/FeatureFlagProvider.tsx @@ -1,22 +1,24 @@ import { - getEnvironment, FeatureFlag, FeatureFlagID, + getEnvironment, Platform, } from "@waveshq/walletkit-core"; import React, { createContext, + PropsWithChildren, ReactElement, useContext, useEffect, + useMemo, useState, - PropsWithChildren, } from "react"; import { satisfies } from "semver"; -import { useNetworkContext } from "./NetworkContext"; -import { useServiceProviderContext } from "./StoreServiceProvider"; + import { useGetFeatureFlagsQuery, usePrefetch } from "../store"; import { BaseLogger } from "./logger"; +import { useNetworkContext } from "./NetworkContext"; +import { useServiceProviderContext } from "./StoreServiceProvider"; const MAX_RETRY = 3; export interface FeatureFlagContextI { @@ -48,8 +50,14 @@ export interface FeatureFlagProviderProps extends PropsWithChildren<{}> { export function FeatureFlagProvider( props: FeatureFlagProviderProps ): JSX.Element | null { - const { logger, api, releaseChannel, platformOS, nativeApplicationVersion } = - props; + const { + logger, + api, + releaseChannel, + platformOS, + nativeApplicationVersion, + children, + } = props; const { network } = useNetworkContext(); const { url, isCustomUrl } = useServiceProviderContext(); const { @@ -89,6 +97,19 @@ export function FeatureFlagProvider( ); } + function checkFeatureStage(feature: FeatureFlag): boolean { + switch (feature.stage) { + case "alpha": + return getEnvironment(releaseChannel).debug; + case "beta": + return enabledFeatures.includes(feature.id); + case "public": + return true; + default: + return false; + } + } + function isFeatureAvailable(featureId: FeatureFlagID): boolean { return featureFlags.some((flag: FeatureFlag) => { if ( @@ -109,19 +130,6 @@ export function FeatureFlagProvider( }); } - function checkFeatureStage(feature: FeatureFlag): boolean { - switch (feature.stage) { - case "alpha": - return getEnvironment(releaseChannel).debug; - case "beta": - return enabledFeatures.includes(feature.id); - case "public": - return true; - default: - return false; - } - } - const updateEnabledFeatures = async ( flags: FeatureFlagID[] ): Promise => { @@ -138,6 +146,24 @@ export function FeatureFlagProvider( .catch((err) => logger.error(err)); }, []); + const context: FeatureFlagContextI = useMemo( + () => ({ + featureFlags, + enabledFeatures, + updateEnabledFeatures, + isFeatureAvailable, + isBetaFeature, + hasBetaFeatures: featureFlags.some( + (flag) => + satisfies(appVersion, flag.version) && + flag.networks?.includes(network) && + flag.platforms?.includes(platformOS) && + flag.stage === "beta" + ), + }), + [featureFlags, appVersion, network, platformOS] + ); + /* If service provider === custom, we keep showing the app regardless if feature flags loaded to ensure app won't be stuck on white screen Note: return null === app will be stuck at white screen until the feature flags API are applied @@ -146,28 +172,14 @@ export function FeatureFlagProvider( return null; } - const context: FeatureFlagContextI = { - featureFlags, - enabledFeatures, - updateEnabledFeatures, - isFeatureAvailable, - isBetaFeature, - hasBetaFeatures: featureFlags.some( - (flag) => - satisfies(appVersion, flag.version) && - flag.networks?.includes(network) && - flag.platforms?.includes(platformOS) && - flag.stage === "beta" - ), - }; - if (isError && !isLoading && retries < MAX_RETRY) { + // eslint-disable-next-line react/jsx-no-useless-fragment return <>; } return ( - {props.children} + {children} ); } From c2648637375f5a08586d90f32b6c4c876d1c5148 Mon Sep 17 00:00:00 2001 From: Keng Ye Date: Thu, 16 Feb 2023 15:43:15 +0800 Subject: [PATCH 3/3] wip: add test for featureflagprovider --- .../src/contexts/FeatureFlagProvider.test.tsx | 80 +++++++++++++++++++ .../FeatureFlagProvider.test.tsx.snap | 74 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 packages/walletkit-ui/src/contexts/FeatureFlagProvider.test.tsx create mode 100644 packages/walletkit-ui/src/contexts/__snapshots__/FeatureFlagProvider.test.tsx.snap diff --git a/packages/walletkit-ui/src/contexts/FeatureFlagProvider.test.tsx b/packages/walletkit-ui/src/contexts/FeatureFlagProvider.test.tsx new file mode 100644 index 0000000..0adf8d9 --- /dev/null +++ b/packages/walletkit-ui/src/contexts/FeatureFlagProvider.test.tsx @@ -0,0 +1,80 @@ +/** @jest-environment jsdom */ +import { render, screen } from "@testing-library/react"; +import { EnvironmentNetwork } from "@waveshq/walletkit-core"; +import React, { PropsWithChildren, useEffect } from "react"; + +import { + FeatureFlagProvider, + useFeatureFlagContext, +} from "./FeatureFlagProvider"; +import { StoreServiceProvider } from "./StoreServiceProvider"; + +function TestingComponent(): JSX.Element { + // const { hasBetaFeatures } = useFeatureFlagContext(); + return ( +
+ {/*

{hasBetaFeatures}

*/} +

{true}

+
+ ); +} +const mockApi = { + get: jest.fn(), + set: jest.fn(), +}; +const consoleLog = jest.spyOn(console, "log").mockImplementation(jest.fn); +const consoleError = jest.spyOn(console, "error").mockImplementation(jest.fn); +const logger = { error: () => consoleError, info: () => consoleLog }; + +jest.mock("./NetworkContext", () => ({ + useNetworkContext: () => ({ + network: EnvironmentNetwork.RemotePlayground, + networkName: "regtest", + updateNetwork: jest.fn(), + }), +})); + +// jest.mock("./StoreServiceProvider", () => ({ +// useServiceProviderContext: () => { +// return { +// url: "", +// defaultUrl: "", +// isCustomUrl: false, +// setUrl: jest.fn(), +// }; +// }, +// })); + +describe.skip("Feature flag provider", () => { + it.only("should match snapshot", () => { + const rendered = render( + + + + + + ); + expect(rendered).toMatchSnapshot(); + }); + + it("should render hasBetaFeatures flag", () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId("flag").textContent).toStrictEqual("true"); + }); +}); diff --git a/packages/walletkit-ui/src/contexts/__snapshots__/FeatureFlagProvider.test.tsx.snap b/packages/walletkit-ui/src/contexts/__snapshots__/FeatureFlagProvider.test.tsx.snap new file mode 100644 index 0000000..4577186 --- /dev/null +++ b/packages/walletkit-ui/src/contexts/__snapshots__/FeatureFlagProvider.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Feature flag provider should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+

+

+
+ , + "container":
+
+

+

+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`;