diff --git a/.storybook/decorators/MockedProvidersDecorator.tsx b/.storybook/decorators/MockedProvidersDecorator.tsx index e695b737b..4e846d487 100644 --- a/.storybook/decorators/MockedProvidersDecorator.tsx +++ b/.storybook/decorators/MockedProvidersDecorator.tsx @@ -8,7 +8,6 @@ import { BrowserRouter } from "react-router-dom"; import { ExternalAppProvider } from "../../src/apps/components/ExternalAppContext"; import { DevModeProvider } from "../../src/components/DevModePanel/DevModeProvider"; import { Locale, RawLocaleProvider } from "../../src/components/Locale"; -import { FlagsServiceProvider } from "../../src/hooks/useFlags/flagsService"; import { paletteOverrides, themeOverrides } from "../../src/themeOverrides"; import { Provider as DateProvider } from "../../src/components/Date/DateContext"; @@ -16,6 +15,7 @@ import { TimezoneProvider } from "../../src/components/Timezone"; import MessageManagerProvider from "../../src/components/messages"; import { getAppMountUri } from "../../src/config"; import { ApolloMockedProvider } from "../../testUtils/ApolloMockedProvider"; +import { FeatureFlagsProvider } from "@dashboard/featureFlags"; export const MockedProvidersDecorator: React.FC = ({ children }) => ( @@ -35,7 +35,7 @@ export const MockedProvidersDecorator: React.FC = ({ children }) => ( - +
(
-
+
diff --git a/docs/configuration.md b/docs/configuration.md index ca104ceec..885de6823 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,9 +21,3 @@ Create or edit `.env` file in a root directory or set environment variables with - `APPS_MARKETPLACE_API_URI` - URI of Marketplace API to fetch list of Apps in JSON. - `APPS_TUNNEL_URL_KEYWORDS` - Custom apps tunnel URL keywords. - -- `FLAGS_SERVICE_ENABLED` - Boolean flag determines whether we use external feature flags provider. - If you set `FLAGS_SERVICE_ENABLED` to "true", we'll be using external feature flag provider as source or flags. - If you set `FLAGS_SERVICE_ENABLED` to "false" or not set, we'll use fallback flags from environment variables. - -- `FLAGSMITH_ID` - Flagsmith environment id diff --git a/package-lock.json b/package-lock.json index 11a0801bf..b97c47c45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,7 @@ "@percy/cli": "^1.21.0", "@percy/cypress": "^3.1.2", "@saleor/app-sdk": "0.39.1", + "@swc/jest": "^0.2.26", "@types/apollo-upload-client": "^17.0.2", "@types/color-convert": "^2.0.0", "@types/debug": "^4.1.7", @@ -5501,6 +5502,18 @@ "node": ">=8" } }, + "node_modules/@jest/create-cache-key-function": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-27.5.1.tgz", + "integrity": "sha512-dmH1yW+makpTSURTy8VzdUwFnfQh1G8R+DxO2Ho2FFmBbKFEVm+3jWdvFhE2VqB/LATCTokkP0dotjyQyw5/AQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/@jest/environment": { "version": "27.5.1", "license": "MIT", @@ -11034,6 +11047,22 @@ "node": ">=10" } }, + "node_modules/@swc/jest": { + "version": "0.2.26", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.26.tgz", + "integrity": "sha512-7lAi7q7ShTO3E5Gt1Xqf3pIhRbERxR1DUxvtVa9WKzIB+HGQ7wZP5sYx86zqnaEoKKGhmOoZ7gyW0IRu8Br5+A==", + "dev": true, + "dependencies": { + "@jest/create-cache-key-function": "^27.4.2", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.5", "license": "MIT", @@ -24831,6 +24860,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "node_modules/jsonfile": { "version": "4.0.0", "devOptional": true, @@ -38981,6 +39016,15 @@ } } }, + "@jest/create-cache-key-function": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-27.5.1.tgz", + "integrity": "sha512-dmH1yW+makpTSURTy8VzdUwFnfQh1G8R+DxO2Ho2FFmBbKFEVm+3jWdvFhE2VqB/LATCTokkP0dotjyQyw5/AQ==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1" + } + }, "@jest/environment": { "version": "27.5.1", "optional": true, @@ -42885,6 +42929,16 @@ "integrity": "sha512-73DGsjsJYSzmoRbfomPj5jcQawtK2H0bCDi/1wgfl8NKVOuzrq+PpaTry3lzx+gvTHxUX6mUHV22i7C9ITL74Q==", "optional": true }, + "@swc/jest": { + "version": "0.2.26", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.26.tgz", + "integrity": "sha512-7lAi7q7ShTO3E5Gt1Xqf3pIhRbERxR1DUxvtVa9WKzIB+HGQ7wZP5sYx86zqnaEoKKGhmOoZ7gyW0IRu8Br5+A==", + "dev": true, + "requires": { + "@jest/create-cache-key-function": "^27.4.2", + "jsonc-parser": "^3.2.0" + } + }, "@szmarczak/http-timer": { "version": "4.0.5", "optional": true, @@ -52246,6 +52300,12 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "devOptional": true }, + "jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "jsonfile": { "version": "4.0.0", "devOptional": true, diff --git a/package.json b/package.json index 7318a6f54..7e46a5061 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "@percy/cli": "^1.21.0", "@percy/cypress": "^3.1.2", "@saleor/app-sdk": "0.39.1", + "@swc/jest": "^0.2.26", "@types/apollo-upload-client": "^17.0.2", "@types/color-convert": "^2.0.0", "@types/debug": "^4.1.7", @@ -253,7 +254,7 @@ ], "testEnvironment": "jest-environment-jsdom", "transform": { - "^.+\\.(jsx?|tsx?)$": "babel-jest", + "^.+\\.(jsx?|tsx?)$": "@swc/jest", "^.+\\.(png|svg|jpe?g)$": "jest-file" }, "testRegex": ".*\\.test\\.tsx?$", diff --git a/src/apps/components/AppFrame/AppFrame.tsx b/src/apps/components/AppFrame/AppFrame.tsx index 3257dcc4d..9d479a278 100644 --- a/src/apps/components/AppFrame/AppFrame.tsx +++ b/src/apps/components/AppFrame/AppFrame.tsx @@ -1,14 +1,11 @@ // @ts-strict-ignore import { useAppDashboardUpdates } from "@dashboard/apps/components/AppFrame/useAppDashboardUpdates"; import { useUpdateAppToken } from "@dashboard/apps/components/AppFrame/useUpdateAppToken"; -import { - AppDetailsUrlQueryParams, - prepareFeatureFlagsList, -} from "@dashboard/apps/urls"; -import { useAllFlags } from "@dashboard/hooks/useFlags"; +import { AppDetailsUrlQueryParams } from "@dashboard/apps/urls"; +import { useAllFlags } from "@dashboard/featureFlags"; import { CircularProgress } from "@material-ui/core"; import clsx from "clsx"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback } from "react"; import { AppIFrame } from "./AppIFrame"; import { useStyles } from "./styles"; @@ -76,8 +73,6 @@ export const AppFrame: React.FC = ({ setHandshakeDone(true); }, [appToken, postToExtension, setHandshakeDone]); - const featureFlags = useMemo(() => prepareFeatureFlagsList(flags), [flags]); - useUpdateAppToken({ postToExtension, appToken, @@ -98,7 +93,7 @@ export const AppFrame: React.FC = ({ ref={frameRef} src={src} appId={appId} - featureFlags={featureFlags} + featureFlags={flags} params={params} onLoad={handleLoad} onError={onError} diff --git a/src/apps/components/AppFrame/AppIFrame.tsx b/src/apps/components/AppFrame/AppIFrame.tsx index 373605e71..58565e51b 100644 --- a/src/apps/components/AppFrame/AppIFrame.tsx +++ b/src/apps/components/AppFrame/AppIFrame.tsx @@ -1,5 +1,6 @@ // @ts-strict-ignore import { AppDetailsUrlQueryParams, AppUrls } from "@dashboard/apps/urls"; +import { FlagList } from "@dashboard/featureFlags"; import { ThemeType } from "@saleor/app-sdk/app-bridge"; import { useTheme } from "@saleor/macaw-ui"; import isEqualWith from "lodash/isEqualWith"; @@ -8,7 +9,7 @@ import React, { forwardRef, memo, useEffect, useRef } from "react"; interface AppIFrameProps { appId: string; src: string; - featureFlags: Record; + featureFlags: FlagList; params: AppDetailsUrlQueryParams; onLoad: () => void; onError: () => void; diff --git a/src/apps/components/AppFrame/appActionsHandler.test.ts b/src/apps/components/AppFrame/appActionsHandler.test.ts index 0ee4e6dde..6a9c10333 100644 --- a/src/apps/components/AppFrame/appActionsHandler.test.ts +++ b/src/apps/components/AppFrame/appActionsHandler.test.ts @@ -7,6 +7,27 @@ import { renderHook } from "@testing-library/react-hooks"; import * as ReactIntl from "react-intl"; import { IntlShape } from "react-intl"; +jest.mock("@dashboard/config", () => { + const actualModule = jest.requireActual("@dashboard/config"); + return { + __esModule: true, + ...actualModule, + }; +}); + +jest.mock( + "@dashboard/apps/components/ExternalAppContext/ExternalAppContext", + () => { + const actualModule = jest.requireActual( + "@dashboard/apps/components/ExternalAppContext/ExternalAppContext", + ); + return { + __esModule: true, + ...actualModule, + }; + }, +); + const mockNotify = jest.fn(); const mockCloseExternalApp = jest.fn(); diff --git a/src/apps/urls.test.ts b/src/apps/urls.test.ts index bc4354a20..65983b35c 100644 --- a/src/apps/urls.test.ts +++ b/src/apps/urls.test.ts @@ -2,6 +2,14 @@ import { AppUrls } from "@dashboard/apps/urls"; import * as config from "@dashboard/config"; import { ThemeType } from "@saleor/app-sdk/app-bridge"; +jest.mock("@dashboard/config", () => { + const actualModule = jest.requireActual("@dashboard/config"); + return { + __esModule: true, + ...actualModule, + }; +}); + describe("AppUrls (apps/urls.ts)", () => { describe("isAppDeepUrlChange", () => { it("Returns true if only nested app path changes", () => { diff --git a/src/apps/urls.ts b/src/apps/urls.ts index e27ca10d5..2add6761c 100644 --- a/src/apps/urls.ts +++ b/src/apps/urls.ts @@ -1,5 +1,5 @@ import { getApiUrl } from "@dashboard/config"; -import { FlagWithName } from "@dashboard/hooks/useFlags/types"; +import { FlagList } from "@dashboard/featureFlags"; import { stringifyQs } from "@dashboard/utils/urls"; import { ThemeType } from "@saleor/app-sdk/app-bridge"; import urlJoin from "url-join"; @@ -24,7 +24,7 @@ export interface AppDetailsUrlMountQueryParams { } interface FeatureFlagsQueryParams { - featureFlags?: Record; + featureFlags?: FlagList; } export interface AppDetailsCommonParams { theme: ThemeType; @@ -36,16 +36,6 @@ export type AppDetailsUrlQueryParams = Dialog & export type AppInstallUrlQueryParams = Partial<{ [MANIFEST_ATTR]: string }>; -export const prepareFeatureFlagsList = ( - flags: FlagWithName[], -): Record => - flags.reduce>((acc, flag) => { - if (flag.enabled) { - acc[flag.name] = `${flag.value || true}`; - } - return acc; - }, {}); - export const AppSections = { appsSection: "/apps/", }; diff --git a/src/featureFlags/FeatureFlagsProvider.tsx b/src/featureFlags/FeatureFlagsProvider.tsx new file mode 100644 index 000000000..40eee629d --- /dev/null +++ b/src/featureFlags/FeatureFlagsProvider.tsx @@ -0,0 +1,35 @@ +import LoginLoading from "@dashboard/auth/components/LoginLoading/LoginLoading"; +import React, { ReactNode, useEffect, useState } from "react"; + +import { FlagList } from "./availableFlags"; +import { Provider } from "./context"; +import { useFlagsResolver } from "./FlagsResolver"; +import { AvailableStrategies } from "./strategies"; + +interface FeatureFlagsProviderProps { + children: ReactNode; + strategies: AvailableStrategies[]; +} + +export const FeatureFlagsProvider = ({ + children, + strategies, +}: FeatureFlagsProviderProps) => { + const resolver = useFlagsResolver(strategies); + const [flags, setFlags] = useState(undefined); + const [loading, setLoading] = useState(true); + + const disableLoading = () => setLoading(false); + + useEffect(() => { + resolver + .fetchAll() + .combineWithPriorities() + .then(setFlags) + .finally(disableLoading); + }, []); + + return ( + {loading ? : children} + ); +}; diff --git a/src/featureFlags/FlagContent.test.ts b/src/featureFlags/FlagContent.test.ts new file mode 100644 index 000000000..f23d42488 --- /dev/null +++ b/src/featureFlags/FlagContent.test.ts @@ -0,0 +1,31 @@ +import { FlagValue } from "./FlagContent"; + +describe("featureFlags/FlagValue", () => { + it("creates from string", async () => { + // Arrange + const stringValue = `{ "enabled": true, "payload": "test" }`; + + // Act + const instance = FlagValue.fromString(stringValue); + + // Assert + expect(instance).toEqual({ enabled: true, payload: "test" }); + }); + + it("creates empty", async () => { + // Act + const instance = FlagValue.empty(); + + // Arrange + expect(instance).toEqual({ enabled: false, payload: "" }); + }); + + it("returns a JSON string", async () => { + // Arrange + const instance = new FlagValue(true, "some value"); + + const jsonString = instance.asString(); + + expect(jsonString).toEqual('{"enabled":true,"payload":"some value"}'); + }); +}); diff --git a/src/featureFlags/FlagContent.ts b/src/featureFlags/FlagContent.ts new file mode 100644 index 000000000..97b7ff102 --- /dev/null +++ b/src/featureFlags/FlagContent.ts @@ -0,0 +1,21 @@ +export class FlagValue { + constructor(public enabled: boolean, public payload?: string) {} + + public static fromString(value: string) { + try { + const { enabled, payload } = JSON.parse(value); + + return new FlagValue(enabled, payload); + } catch (e) { + return FlagValue.empty(); + } + } + + public static empty() { + return new FlagValue(false, ""); + } + + public asString() { + return JSON.stringify(this); + } +} diff --git a/src/featureFlags/FlagsResolver.test.ts b/src/featureFlags/FlagsResolver.test.ts new file mode 100644 index 000000000..d8e3e44d6 --- /dev/null +++ b/src/featureFlags/FlagsResolver.test.ts @@ -0,0 +1,102 @@ +import { FlagsResolver } from "./FlagsResolver"; +import { Strategy } from "./Strategy"; + +jest.mock("./availableFlags", () => ({ + isSupported: () => true, +})); + +describe("featureFlags/FlagsResolver", () => { + it("fetches flags data in given order", async () => { + // Arrange + const strategy1 = { + fetchAll: () => ({ + flag1: { enabled: true, payload: "test1" }, + }), + } as unknown as Strategy; + + const strategy2 = { + fetchAll: () => ({ + flag1: { enabled: true, payload: "test2" }, + }), + } as unknown as Strategy; + + const defaultStrategy = { + fetchAll: () => ({ + flag1: { enabled: true, payload: "default" }, + }), + } as unknown as Strategy; + + const resolver = new FlagsResolver([strategy1, strategy2], defaultStrategy); + + // Act + const results = await resolver.fetchAll().getResult(); + + // Assert + expect(results).toEqual([ + { flag1: { enabled: true, payload: "test1" } }, + { flag1: { enabled: true, payload: "test2" } }, + { flag1: { enabled: true, payload: "default" } }, + ]); + }); + + // Arrange + it.each([ + { + strategies: [ + { + fetchAll: () => ({ + flag1: { enabled: true, payload: "test1" }, + }), + } as unknown as Strategy, + ], + expected: { + flag1: { enabled: true, payload: "test1" }, + flagD: { enabled: true, payload: "some default" }, + }, + }, + { + strategies: [ + { + fetchAll: () => ({ + flag1: { enabled: true, payload: "test1-a" }, + }), + } as unknown as Strategy, + { + fetchAll: () => ({ + flag1: { enabled: true, payload: "test1-b" }, + flag2: { enabled: true, payload: "test2-b" }, + }), + } as unknown as Strategy, + { + fetchAll: () => ({ + flag1: { enabled: true, payload: "test1-c" }, + flag2: { enabled: true, payload: "test2-c" }, + }), + } as unknown as Strategy, + ], + expected: { + flag1: { enabled: true, payload: "test1-a" }, + flag2: { enabled: true, payload: "test2-b" }, + flagD: { enabled: true, payload: "some default" }, + }, + }, + ])( + "combines flags acorrding to the order", + async ({ strategies, expected }) => { + const defaultStrategy = { + fetchAll: () => ({ + flag1: { enabled: true, payload: "default" }, + flagD: { enabled: true, payload: "some default" }, + }), + } as unknown as Strategy; + + const resolver = new FlagsResolver(strategies, defaultStrategy); + + // Act + const results = await resolver.fetchAll().combineWithPriorities(); + + // Assert + expect(results).toEqual(expected); + }, + ); +}); diff --git a/src/featureFlags/FlagsResolver/index.ts b/src/featureFlags/FlagsResolver/index.ts new file mode 100644 index 000000000..2b9d89058 --- /dev/null +++ b/src/featureFlags/FlagsResolver/index.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; + +import * as AvailableFlags from "./../availableFlags"; +import { AvailableStrategies } from "./../strategies"; +import { DefaultsStrategy } from "./../strategies/DefaultsStrategy"; +import { Strategy } from "./../Strategy"; +import { reduceFlagListArray } from "./reduceFlagListArray"; + +export class FlagsResolver { + private results: Promise; + private strategies: Strategy[]; + + constructor( + strategies: Strategy[], + defaultStrategy = new DefaultsStrategy(), + ) { + this.results = Promise.resolve([]); + this.strategies = [...strategies, defaultStrategy]; + } + + public fetchAll() { + const promises = this.strategies.map(s => s.fetchAll()); + this.results = Promise.all(promises); + + return this; + } + + public async combineWithPriorities(): Promise { + const list = await this.results; + + return reduceFlagListArray(list); + } + + public getResult() { + return this.results; + } +} + +export const useFlagsResolver = (strategies: AvailableStrategies[]) => { + const resolver = useRef(new FlagsResolver(strategies)); + + return resolver.current; +}; diff --git a/src/featureFlags/FlagsResolver/reduceFlagListArray.ts b/src/featureFlags/FlagsResolver/reduceFlagListArray.ts new file mode 100644 index 000000000..c874ac399 --- /dev/null +++ b/src/featureFlags/FlagsResolver/reduceFlagListArray.ts @@ -0,0 +1,37 @@ +import * as AvailableFlags from "./../availableFlags"; +import { FlagValue } from "./../FlagContent"; + +const byNotEmpty = (p: AvailableFlags.GeneralFlagList) => + Object.keys(p).length > 0; + +const toFlagEntries = ( + p: Array<[string, FlagValue]>, + c: AvailableFlags.GeneralFlagList, +): Array<[string, FlagValue]> => [...p, ...Object.entries(c)]; + +const toWithoutPrefixes = ([name, value]: [string, FlagValue]): [ + string, + FlagValue, +] => [name.replace("FF_", ""), value]; + +const toSingleObject = ( + p: AvailableFlags.GeneralFlagList, + [name, value]: [string, FlagValue], +) => { + if (!AvailableFlags.isSupported(name)) { + return p; + } + + if (!p[name]) { + p[name] = value; + } + + return p; +}; + +export const reduceFlagListArray = (flagListArray: AvailableFlags.FlagList[]) => + flagListArray + .filter(byNotEmpty) + .reduce(toFlagEntries, []) + .map(toWithoutPrefixes) + .reduce(toSingleObject, {} as AvailableFlags.FlagList); diff --git a/src/featureFlags/Strategy.ts b/src/featureFlags/Strategy.ts new file mode 100644 index 000000000..071d70c6e --- /dev/null +++ b/src/featureFlags/Strategy.ts @@ -0,0 +1,9 @@ +import { FlagList } from "./availableFlags"; + +export interface Strategy { + fetchAll(): Promise; +} + +export interface PersistableStrategy extends Strategy { + store?(flags: FlagList): Promise; +} diff --git a/src/featureFlags/availableFlags.ts b/src/featureFlags/availableFlags.ts new file mode 100644 index 000000000..4462db2bf --- /dev/null +++ b/src/featureFlags/availableFlags.ts @@ -0,0 +1,66 @@ +import { FlagValue } from "./FlagContent"; + +interface FlagDefinition { + name: string; + displayName: string; + description: string; + content: { + enabled: boolean; + payload?: string; + }; +} + +const AVAILABLE_FLAGS = [ + /* + Before use any flag pleease an entry within this array, + so the TS will infer the types, example: + + { + name: "flag1", + displayName: "Flag 1", + description: "some description", + content: { enabled: false, value: "default" }, + } as const, + */ + + { + name: "flag1", + displayName: "Flag 1", + description: "some description", + content: { enabled: false, payload: "default" }, + } as const, + { + name: "flag2", + displayName: "Flag 2", + description: "some description 2", + content: { enabled: false, payload: "default2" }, + } as const, +] satisfies FlagDefinition[]; + +type TypedEntry = (typeof AVAILABLE_FLAGS)[number]; +type GeneralEntry = TypedEntry extends never ? FlagDefinition : TypedEntry; +export type Entry = TypedEntry; +export type Name = TypedEntry["name"]; +export type FlagList = Record; +export type GeneralFlagList = TypedEntry extends never + ? Record + : FlagList; + +const toFlagValue = (p: GeneralFlagList, c: GeneralEntry) => { + p[c.name] = new FlagValue(c.content.enabled, c.content.payload); + return p; +}; + +export const isSupported = (name: string): name is Name => + AVAILABLE_FLAGS.some( + (e: FlagDefinition) => e.name === name || `FF_${e.name}` === name, + ); + +export const asFlagValue = () => + AVAILABLE_FLAGS.reduce(toFlagValue, {} as FlagList); + +export const asFlagInfoArray = (list: GeneralFlagList) => + AVAILABLE_FLAGS.map((el: GeneralEntry) => ({ + ...el, + content: list[el.name], + })); diff --git a/src/featureFlags/context.ts b/src/featureFlags/context.ts new file mode 100644 index 000000000..8faac289f --- /dev/null +++ b/src/featureFlags/context.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; + +import { FlagList } from "./availableFlags"; + +const FeatureFlagsContext = createContext(undefined); + +export const Provider = FeatureFlagsContext.Provider; + +export const useFeatureFlagContext = () => { + const context = useContext(FeatureFlagsContext); + + if (!context) { + throw new Error("FeatureFlagsContext must be uset within its provider."); + } + + return context; +}; diff --git a/src/featureFlags/index.tsx b/src/featureFlags/index.tsx new file mode 100644 index 000000000..6b4143172 --- /dev/null +++ b/src/featureFlags/index.tsx @@ -0,0 +1,5 @@ +export { FeatureFlagsProvider } from "./FeatureFlagsProvider"; +export { EnvVarsStrategy, LocalStorageStrategy } from "./strategies"; +export { useFlag } from "./useFlag"; +export { useAllFlags } from "./useAllFlags"; +export type { FlagList } from "./availableFlags"; diff --git a/src/featureFlags/strategies/DefaultsStrategy.ts b/src/featureFlags/strategies/DefaultsStrategy.ts new file mode 100644 index 000000000..0c76d9911 --- /dev/null +++ b/src/featureFlags/strategies/DefaultsStrategy.ts @@ -0,0 +1,8 @@ +import * as AvailableFlags from "../availableFlags"; +import { Strategy } from "../Strategy"; + +export class DefaultsStrategy implements Strategy { + fetchAll(): Promise { + return Promise.resolve(AvailableFlags.asFlagValue()); + } +} diff --git a/src/featureFlags/strategies/EnvVarsStrategy.ts b/src/featureFlags/strategies/EnvVarsStrategy.ts new file mode 100644 index 000000000..2c94ff459 --- /dev/null +++ b/src/featureFlags/strategies/EnvVarsStrategy.ts @@ -0,0 +1,5 @@ +import { ObjectStorageStrategy } from "./ObjectStorageStrategy"; + +export class EnvVarsStrategy extends ObjectStorageStrategy { + sourceObject = FLAGS; +} diff --git a/src/featureFlags/strategies/LocalStorageStrategy.ts b/src/featureFlags/strategies/LocalStorageStrategy.ts new file mode 100644 index 000000000..24a1a600b --- /dev/null +++ b/src/featureFlags/strategies/LocalStorageStrategy.ts @@ -0,0 +1,5 @@ +import { ObjectStorageStrategy } from "./ObjectStorageStrategy"; + +export class LocalStorageStrategy extends ObjectStorageStrategy { + sourceObject = localStorage; +} diff --git a/src/featureFlags/strategies/ObjectStorageStrategy.ts b/src/featureFlags/strategies/ObjectStorageStrategy.ts new file mode 100644 index 000000000..3ae728c1e --- /dev/null +++ b/src/featureFlags/strategies/ObjectStorageStrategy.ts @@ -0,0 +1,26 @@ +import { FlagList, GeneralFlagList } from "../availableFlags"; +import { FlagValue } from "../FlagContent"; +import { Strategy } from "../Strategy"; +import * as AvailableFlags from "./../availableFlags"; + +const byFlagPrefix = ([key, _]: [string, string]) => key.startsWith("FF"); + +const toFlagList = (p: GeneralFlagList, [name, value]: [string, string]) => { + if (AvailableFlags.isSupported(name)) { + p[name] = FlagValue.fromString(value); + } + + return p; +}; + +export abstract class ObjectStorageStrategy implements Strategy { + sourceObject: Record = {}; + + fetchAll(): Promise { + const result = Object.entries(this.sourceObject) + .filter(byFlagPrefix) + .reduce(toFlagList, {} as FlagList); + + return Promise.resolve(result); + } +} diff --git a/src/featureFlags/strategies/index.ts b/src/featureFlags/strategies/index.ts new file mode 100644 index 000000000..f9bfe3170 --- /dev/null +++ b/src/featureFlags/strategies/index.ts @@ -0,0 +1,6 @@ +import { EnvVarsStrategy } from "./EnvVarsStrategy"; +import { LocalStorageStrategy } from "./LocalStorageStrategy"; + +export { EnvVarsStrategy, LocalStorageStrategy }; + +export type AvailableStrategies = EnvVarsStrategy | LocalStorageStrategy; diff --git a/src/featureFlags/useAllFlags.ts b/src/featureFlags/useAllFlags.ts new file mode 100644 index 000000000..e3202a92f --- /dev/null +++ b/src/featureFlags/useAllFlags.ts @@ -0,0 +1,7 @@ +import { useFeatureFlagContext } from "./context"; + +export const useAllFlags = () => { + const context = useFeatureFlagContext(); + + return context; +}; diff --git a/src/featureFlags/useFlag.ts b/src/featureFlags/useFlag.ts new file mode 100644 index 000000000..7393f232f --- /dev/null +++ b/src/featureFlags/useFlag.ts @@ -0,0 +1,14 @@ +import { Name as FlagName } from "./availableFlags"; +import { useFeatureFlagContext } from "./context"; +import { FlagValue } from "./FlagContent"; + +export const useFlag = (name: FlagName) => { + const context = useFeatureFlagContext(); + const flag = context[name]; + + if (!flag) { + return FlagValue.empty(); + } + + return context[name]; +}; diff --git a/src/featureFlags/useFlagsInfo.ts b/src/featureFlags/useFlagsInfo.ts new file mode 100644 index 000000000..fcbc244b9 --- /dev/null +++ b/src/featureFlags/useFlagsInfo.ts @@ -0,0 +1,8 @@ +import * as AvailableFlags from "./availableFlags"; +import { useAllFlags } from "./useAllFlags"; + +export const useFlagsInfo = () => { + const allFlags = useAllFlags(); + + return AvailableFlags.asFlagInfoArray(allFlags); +}; diff --git a/src/hooks/useFlags/env/const.ts b/src/hooks/useFlags/env/const.ts deleted file mode 100644 index 417bb1bc4..000000000 --- a/src/hooks/useFlags/env/const.ts +++ /dev/null @@ -1 +0,0 @@ -export const ENV_FLAG_PREFIX = "FF_"; diff --git a/src/hooks/useFlags/env/helpers.ts b/src/hooks/useFlags/env/helpers.ts deleted file mode 100644 index e810501b9..000000000 --- a/src/hooks/useFlags/env/helpers.ts +++ /dev/null @@ -1,34 +0,0 @@ -import camelCase from "lodash/camelCase"; -import snakeCase from "lodash/snakeCase"; - -import { ENV_FLAG_PREFIX } from "./const"; - -export const envNameToFlagName = (envName: string) => { - const name = envName.split(ENV_FLAG_PREFIX)[1]; - return camelCase(name); -}; - -export const flagNameToEnvName = (flagName: string) => - `${ENV_FLAG_PREFIX}${snakeCase(flagName).toUpperCase()}`; - -/** - Referencing an virtual constant FLAGS, prepared by Vite. It populates env-based feature flags into client-side, under the virtual property FLAGS, - Please do not use FLAGS constant directly anywhere. -*/ -export const readFlagFromEnv = (flagName: string): string | undefined => { - if (FLAGS) { - return FLAGS[flagName]; - } - - return undefined; -}; - -export const readAllFlagsFromEnv = (): Record => { - if (FLAGS) { - return FLAGS; - } - - return {}; -}; - -export const isFlagEnabled = (flag: string) => flag !== "" && flag !== "false"; diff --git a/src/hooks/useFlags/env/index.ts b/src/hooks/useFlags/env/index.ts deleted file mode 100644 index 48904003e..000000000 --- a/src/hooks/useFlags/env/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useEnvFlags } from "./useEnvFlags"; -export { useAllEnvFlags } from "./useAllEnvFlags"; diff --git a/src/hooks/useFlags/env/useAllEnvFlags.test.tsx b/src/hooks/useFlags/env/useAllEnvFlags.test.tsx deleted file mode 100644 index a3140939a..000000000 --- a/src/hooks/useFlags/env/useAllEnvFlags.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; - -import { useAllEnvFlags } from "./useAllEnvFlags"; - -describe("useAllEnvFlags hook", () => { - afterEach(() => { - delete FLAGS.FF_FLAG_ONE; - delete FLAGS.FF_FLAG_TWO; - }); - - test("should return all environment flags", () => { - // Arrange && Act - FLAGS.FF_FLAG_ONE = "1"; - FLAGS.FF_FLAG_TWO = "2"; - - const { result } = renderHook(() => useAllEnvFlags()); - - // Assert - expect(result.current).toEqual([ - { - name: "flagOne", - enabled: true, - value: "1", - }, - { - name: "flagTwo", - enabled: true, - value: "2", - }, - ]); - }); - - test("should return empty array when there is no flags", () => { - // Arrange && Act - const { result } = renderHook(() => useAllEnvFlags()); - - // Assert - expect(result.current).toEqual([]); - }); - - test("should return array with disabled flags", () => { - // Arrange && Act - FLAGS.FF_FLAG_ONE = ""; - FLAGS.FF_FLAG_TWO = "false"; - - const { result } = renderHook(() => useAllEnvFlags()); - - // Assert - expect(result.current).toEqual([ - { - name: "flagOne", - enabled: false, - value: "", - }, - { - name: "flagTwo", - enabled: false, - value: "false", - }, - ]); - }); -}); diff --git a/src/hooks/useFlags/env/useAllEnvFlags.ts b/src/hooks/useFlags/env/useAllEnvFlags.ts deleted file mode 100644 index beb84ecad..000000000 --- a/src/hooks/useFlags/env/useAllEnvFlags.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { FlagWithName } from "../types"; -import { - envNameToFlagName, - isFlagEnabled, - readAllFlagsFromEnv, -} from "./helpers"; - -export const useAllEnvFlags = (): FlagWithName[] => { - const flags = readAllFlagsFromEnv(); - - return Object.entries(flags).map(([flagKey, flagValue]) => ({ - name: envNameToFlagName(flagKey), - enabled: isFlagEnabled(flagValue), - value: flagValue, - })); -}; diff --git a/src/hooks/useFlags/env/useEnvFlags.test.tsx b/src/hooks/useFlags/env/useEnvFlags.test.tsx deleted file mode 100644 index a107d9953..000000000 --- a/src/hooks/useFlags/env/useEnvFlags.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; - -import { useEnvFlags } from "./useEnvFlags"; - -describe("useEnvFlags hook", () => { - afterEach(() => { - delete FLAGS.FF_FLAG_ONE; - delete FLAGS.FF_FLAG_TWO; - }); - - test("should return results for given flags when exists in process.env", () => { - // Arrange && Act - FLAGS.FF_FLAG_ONE = "1"; - FLAGS.FF_FLAG_TWO = "2"; - - const { result } = renderHook(() => useEnvFlags(["flagOne", "flag_two"])); - - // Assert - expect(result.current).toEqual({ - flagOne: { - enabled: true, - value: "1", - }, - flag_two: { - enabled: true, - value: "2", - }, - }); - }); - - test("should return results for given flags even when flag does not exist", () => { - // Arrange && Act - - const { result } = renderHook(() => useEnvFlags(["flagOne", "flag_two"])); - - // Assert - expect(result.current).toEqual({ - flagOne: { - enabled: false, - value: "", - }, - flag_two: { - enabled: false, - value: "", - }, - }); - }); - - test("should return empty object when not flags provided", () => { - // Arrange && Act - - const { result } = renderHook(() => useEnvFlags([])); - - // Assert - expect(result.current).toEqual({}); - }); - - test("should return array with disabled flags", () => { - // Arrange && Act - FLAGS.FF_FLAG_ONE = ""; - FLAGS.FF_FLAG_TWO = "false"; - - const { result } = renderHook(() => useEnvFlags(["flagOne", "flag_two"])); - - // Assert - expect(result.current).toEqual({ - flagOne: { - enabled: false, - value: "", - }, - flag_two: { - enabled: false, - value: "false", - }, - }); - }); -}); diff --git a/src/hooks/useFlags/env/useEnvFlags.ts b/src/hooks/useFlags/env/useEnvFlags.ts deleted file mode 100644 index 92f13ee90..000000000 --- a/src/hooks/useFlags/env/useEnvFlags.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FlagsResults } from "../types"; -import { flagNameToEnvName, isFlagEnabled, readFlagFromEnv } from "./helpers"; - -export const useEnvFlags = ( - flags: readonly [...T], -): FlagsResults => - flags.reduce((acc, flag) => { - const envFlag = readFlagFromEnv(flagNameToEnvName(flag)); - - if (envFlag) { - acc[flag] = { - enabled: isFlagEnabled(envFlag), - value: envFlag, - }; - } else { - acc[flag] = { - enabled: false, - value: "", - }; - } - - return acc; - }, {} as FlagsResults); diff --git a/src/hooks/useFlags/flagsService/flagsServiceProvider.tsx b/src/hooks/useFlags/flagsService/flagsServiceProvider.tsx deleted file mode 100644 index f99316d12..000000000 --- a/src/hooks/useFlags/flagsService/flagsServiceProvider.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; - -interface FlagsServiceProviderProps { - children: React.ReactElement; -} - -export const FlagsServiceProvider = ({ children }: FlagsServiceProviderProps) => - children; diff --git a/src/hooks/useFlags/flagsService/index.ts b/src/hooks/useFlags/flagsService/index.ts deleted file mode 100644 index 534bcfbc3..000000000 --- a/src/hooks/useFlags/flagsService/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useServiceFlags } from "./useServiceFlags"; -export { useAllServiceFlags } from "./useAllServiceFlags"; -export { FlagsServiceProvider } from "./flagsServiceProvider"; diff --git a/src/hooks/useFlags/flagsService/useAllServiceFlags.test.tsx b/src/hooks/useFlags/flagsService/useAllServiceFlags.test.tsx deleted file mode 100644 index e114d60f8..000000000 --- a/src/hooks/useFlags/flagsService/useAllServiceFlags.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; - -import { useAllServiceFlags } from "./useAllServiceFlags"; - -afterAll(() => { - jest.clearAllMocks(); -}); - -describe("useAllServiceFlags hook", () => { - test("should return all flags from flag service", () => { - // Arrange && Act - const { result } = renderHook(() => useAllServiceFlags()); - - // Assert - expect(result.current).toEqual([]); - }); -}); diff --git a/src/hooks/useFlags/flagsService/useAllServiceFlags.ts b/src/hooks/useFlags/flagsService/useAllServiceFlags.ts deleted file mode 100644 index ae59c7a1c..000000000 --- a/src/hooks/useFlags/flagsService/useAllServiceFlags.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { FlagWithName } from "../types"; - -export const useAllServiceFlags = (): FlagWithName[] => []; diff --git a/src/hooks/useFlags/flagsService/useServiceFlags.test.tsx b/src/hooks/useFlags/flagsService/useServiceFlags.test.tsx deleted file mode 100644 index edf542188..000000000 --- a/src/hooks/useFlags/flagsService/useServiceFlags.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; - -import { useServiceFlags } from "./useServiceFlags"; - -describe("useServiceFlags", () => { - test("should return flags with values", () => { - // Arrange && Ac - const { result } = renderHook(() => - useServiceFlags(["flagOne", "flag_two"]), - ); - - // Assert - expect(result.current).toEqual({ - flagOne: { - enabled: false, - value: "", - }, - flag_two: { - enabled: false, - value: "", - }, - }); - }); - - test("should return empty object when not flags provided", () => { - // Arrange && Act - const { result } = renderHook(() => useServiceFlags([])); - - // Assert - expect(result.current).toEqual({}); - }); -}); diff --git a/src/hooks/useFlags/flagsService/useServiceFlags.ts b/src/hooks/useFlags/flagsService/useServiceFlags.ts deleted file mode 100644 index 4e35c9fa2..000000000 --- a/src/hooks/useFlags/flagsService/useServiceFlags.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FlagsResults } from "../types"; - -export const useServiceFlags = ( - flags: readonly [...T], -): FlagsResults => - flags.reduce((acc, flag) => { - acc[flag] = { - enabled: false, - value: "", - }; - - return acc; - }, {} as FlagsResults); diff --git a/src/hooks/useFlags/index.ts b/src/hooks/useFlags/index.ts deleted file mode 100644 index 9d93e6073..000000000 --- a/src/hooks/useFlags/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useAllEnvFlags, useEnvFlags } from "./env"; -import { useAllServiceFlags, useServiceFlags } from "./flagsService"; - -export const useFlags = FLAGS_SERVICE_ENABLED ? useServiceFlags : useEnvFlags; -export const useAllFlags = FLAGS_SERVICE_ENABLED - ? useAllServiceFlags - : useAllEnvFlags; diff --git a/src/hooks/useFlags/types.ts b/src/hooks/useFlags/types.ts deleted file mode 100644 index 84f027809..000000000 --- a/src/hooks/useFlags/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface Flag { - value: string; - enabled: boolean; -} - -export interface FlagWithName extends Flag { - name: string; -} - -export type FlagsResults = { - [Key in T[number]]: Flag; -}; diff --git a/src/index.tsx b/src/index.tsx index 4344a5268..986fbe963 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -52,11 +52,15 @@ import CustomAppsSection from "./custom-apps"; import { CustomAppSections } from "./custom-apps/urls"; import { CustomerSection } from "./customers"; import DiscountSection from "./discounts"; +import { + EnvVarsStrategy, + FeatureFlagsProvider, + LocalStorageStrategy, +} from "./featureFlags"; import GiftCardSection from "./giftCards"; import { giftCardsSectionUrlName } from "./giftCards/urls"; import { apolloClient, saleorClient } from "./graphql/client"; import HomePage from "./home"; -import { FlagsServiceProvider } from "./hooks/useFlags/flagsService"; import { useLocationState } from "./hooks/useLocationState"; import { commonMessages } from "./intl"; import NavigationSection from "./navigation"; @@ -117,7 +121,12 @@ const App: React.FC = () => ( - + @@ -131,7 +140,7 @@ const App: React.FC = () => ( - + diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx index 1c0540fa1..afce9c8ce 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx @@ -17,6 +17,14 @@ import ProductUpdatePage, { ProductUpdatePageProps } from "./ProductUpdatePage"; const product = productFixture(placeholderImage); +jest.mock("@dashboard/hooks/useNavigator", () => { + const actualModule = jest.requireActual("@dashboard/hooks/useNavigator"); + return { + __esModule: true, + ...actualModule, + }; +}); + const onSubmit = jest.fn(); const useNavigator = jest.spyOn(_useNavigator, "default"); jest.mock("@dashboard/components/RichTextEditor/RichTextEditor"); diff --git a/types.d.ts b/types.d.ts index 6cfd3629f..b686eb07f 100644 --- a/types.d.ts +++ b/types.d.ts @@ -6,7 +6,6 @@ declare module "*.svg" { } declare const FLAGS_SERVICE_ENABLED: boolean; -declare const FLAGSMITH_ID: string; declare const FLAGS: Record; declare interface Window { diff --git a/vite.config.js b/vite.config.js index e08b380f5..cb070fd96 100644 --- a/vite.config.js +++ b/vite.config.js @@ -45,7 +45,6 @@ export default defineConfig(({ command, mode }) => { DEMO_MODE, CUSTOM_VERSION, FLAGS_SERVICE_ENABLED, - FLAGSMITH_ID, } = env; const base = STATIC_URL ?? "/"; @@ -132,7 +131,6 @@ export default defineConfig(({ command, mode }) => { */ ...(isDev ? { global: {} } : {}), FLAGS_SERVICE_ENABLED: FLAGS_SERVICE_ENABLED === "true", - FLAGSMITH_ID: JSON.stringify(FLAGSMITH_ID), // Keep all feature flags from env in global variable FLAGS: JSON.stringify(featureFlagsEnvs), };