Feature flags strategies (#3800)

* Feature flags

* Feature flags

* Feature flags

* Feature flags

* Feature flags

* Feature flags

* Types

* use @swc/jest

* Avoid calling constructors inside

* Types

* Types

* remove flagsmith

* Change to payload

* Change to payload

* Update tests

* Split resolver
This commit is contained in:
Patryk Andrzejewski 2023-06-27 13:30:20 +02:00 committed by GitHub
parent f4e6ab4101
commit 3118741db8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 567 additions and 348 deletions

View file

@ -8,7 +8,6 @@ import { BrowserRouter } from "react-router-dom";
import { ExternalAppProvider } from "../../src/apps/components/ExternalAppContext"; import { ExternalAppProvider } from "../../src/apps/components/ExternalAppContext";
import { DevModeProvider } from "../../src/components/DevModePanel/DevModeProvider"; import { DevModeProvider } from "../../src/components/DevModePanel/DevModeProvider";
import { Locale, RawLocaleProvider } from "../../src/components/Locale"; import { Locale, RawLocaleProvider } from "../../src/components/Locale";
import { FlagsServiceProvider } from "../../src/hooks/useFlags/flagsService";
import { paletteOverrides, themeOverrides } from "../../src/themeOverrides"; import { paletteOverrides, themeOverrides } from "../../src/themeOverrides";
import { Provider as DateProvider } from "../../src/components/Date/DateContext"; 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 MessageManagerProvider from "../../src/components/messages";
import { getAppMountUri } from "../../src/config"; import { getAppMountUri } from "../../src/config";
import { ApolloMockedProvider } from "../../testUtils/ApolloMockedProvider"; import { ApolloMockedProvider } from "../../testUtils/ApolloMockedProvider";
import { FeatureFlagsProvider } from "@dashboard/featureFlags";
export const MockedProvidersDecorator: React.FC = ({ children }) => ( export const MockedProvidersDecorator: React.FC = ({ children }) => (
<ApolloMockedProvider> <ApolloMockedProvider>
@ -35,7 +35,7 @@ export const MockedProvidersDecorator: React.FC = ({ children }) => (
<ThemeProvider> <ThemeProvider>
<BrowserRouter basename={getAppMountUri()}> <BrowserRouter basename={getAppMountUri()}>
<ExternalAppProvider> <ExternalAppProvider>
<FlagsServiceProvider> <FeatureFlagsProvider strategies={[]}>
<MessageManagerProvider> <MessageManagerProvider>
<DevModeProvider> <DevModeProvider>
<div <div
@ -47,7 +47,7 @@ export const MockedProvidersDecorator: React.FC = ({ children }) => (
</div> </div>
</DevModeProvider> </DevModeProvider>
</MessageManagerProvider> </MessageManagerProvider>
</FlagsServiceProvider> </FeatureFlagsProvider>
</ExternalAppProvider> </ExternalAppProvider>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>

View file

@ -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_MARKETPLACE_API_URI` - URI of Marketplace API to fetch list of Apps in JSON.
- `APPS_TUNNEL_URL_KEYWORDS` - Custom apps tunnel URL keywords. - `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

60
package-lock.json generated
View file

@ -118,6 +118,7 @@
"@percy/cli": "^1.21.0", "@percy/cli": "^1.21.0",
"@percy/cypress": "^3.1.2", "@percy/cypress": "^3.1.2",
"@saleor/app-sdk": "0.39.1", "@saleor/app-sdk": "0.39.1",
"@swc/jest": "^0.2.26",
"@types/apollo-upload-client": "^17.0.2", "@types/apollo-upload-client": "^17.0.2",
"@types/color-convert": "^2.0.0", "@types/color-convert": "^2.0.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",
@ -5501,6 +5502,18 @@
"node": ">=8" "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": { "node_modules/@jest/environment": {
"version": "27.5.1", "version": "27.5.1",
"license": "MIT", "license": "MIT",
@ -11034,6 +11047,22 @@
"node": ">=10" "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": { "node_modules/@szmarczak/http-timer": {
"version": "4.0.5", "version": "4.0.5",
"license": "MIT", "license": "MIT",
@ -24831,6 +24860,12 @@
"node": ">=6" "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": { "node_modules/jsonfile": {
"version": "4.0.0", "version": "4.0.0",
"devOptional": true, "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": { "@jest/environment": {
"version": "27.5.1", "version": "27.5.1",
"optional": true, "optional": true,
@ -42885,6 +42929,16 @@
"integrity": "sha512-73DGsjsJYSzmoRbfomPj5jcQawtK2H0bCDi/1wgfl8NKVOuzrq+PpaTry3lzx+gvTHxUX6mUHV22i7C9ITL74Q==", "integrity": "sha512-73DGsjsJYSzmoRbfomPj5jcQawtK2H0bCDi/1wgfl8NKVOuzrq+PpaTry3lzx+gvTHxUX6mUHV22i7C9ITL74Q==",
"optional": true "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": { "@szmarczak/http-timer": {
"version": "4.0.5", "version": "4.0.5",
"optional": true, "optional": true,
@ -52246,6 +52300,12 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"devOptional": true "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": { "jsonfile": {
"version": "4.0.0", "version": "4.0.0",
"devOptional": true, "devOptional": true,

View file

@ -125,6 +125,7 @@
"@percy/cli": "^1.21.0", "@percy/cli": "^1.21.0",
"@percy/cypress": "^3.1.2", "@percy/cypress": "^3.1.2",
"@saleor/app-sdk": "0.39.1", "@saleor/app-sdk": "0.39.1",
"@swc/jest": "^0.2.26",
"@types/apollo-upload-client": "^17.0.2", "@types/apollo-upload-client": "^17.0.2",
"@types/color-convert": "^2.0.0", "@types/color-convert": "^2.0.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",
@ -253,7 +254,7 @@
], ],
"testEnvironment": "jest-environment-jsdom", "testEnvironment": "jest-environment-jsdom",
"transform": { "transform": {
"^.+\\.(jsx?|tsx?)$": "babel-jest", "^.+\\.(jsx?|tsx?)$": "@swc/jest",
"^.+\\.(png|svg|jpe?g)$": "jest-file" "^.+\\.(png|svg|jpe?g)$": "jest-file"
}, },
"testRegex": ".*\\.test\\.tsx?$", "testRegex": ".*\\.test\\.tsx?$",

View file

@ -1,14 +1,11 @@
// @ts-strict-ignore // @ts-strict-ignore
import { useAppDashboardUpdates } from "@dashboard/apps/components/AppFrame/useAppDashboardUpdates"; import { useAppDashboardUpdates } from "@dashboard/apps/components/AppFrame/useAppDashboardUpdates";
import { useUpdateAppToken } from "@dashboard/apps/components/AppFrame/useUpdateAppToken"; import { useUpdateAppToken } from "@dashboard/apps/components/AppFrame/useUpdateAppToken";
import { import { AppDetailsUrlQueryParams } from "@dashboard/apps/urls";
AppDetailsUrlQueryParams, import { useAllFlags } from "@dashboard/featureFlags";
prepareFeatureFlagsList,
} from "@dashboard/apps/urls";
import { useAllFlags } from "@dashboard/hooks/useFlags";
import { CircularProgress } from "@material-ui/core"; import { CircularProgress } from "@material-ui/core";
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback } from "react";
import { AppIFrame } from "./AppIFrame"; import { AppIFrame } from "./AppIFrame";
import { useStyles } from "./styles"; import { useStyles } from "./styles";
@ -76,8 +73,6 @@ export const AppFrame: React.FC<Props> = ({
setHandshakeDone(true); setHandshakeDone(true);
}, [appToken, postToExtension, setHandshakeDone]); }, [appToken, postToExtension, setHandshakeDone]);
const featureFlags = useMemo(() => prepareFeatureFlagsList(flags), [flags]);
useUpdateAppToken({ useUpdateAppToken({
postToExtension, postToExtension,
appToken, appToken,
@ -98,7 +93,7 @@ export const AppFrame: React.FC<Props> = ({
ref={frameRef} ref={frameRef}
src={src} src={src}
appId={appId} appId={appId}
featureFlags={featureFlags} featureFlags={flags}
params={params} params={params}
onLoad={handleLoad} onLoad={handleLoad}
onError={onError} onError={onError}

View file

@ -1,5 +1,6 @@
// @ts-strict-ignore // @ts-strict-ignore
import { AppDetailsUrlQueryParams, AppUrls } from "@dashboard/apps/urls"; import { AppDetailsUrlQueryParams, AppUrls } from "@dashboard/apps/urls";
import { FlagList } from "@dashboard/featureFlags";
import { ThemeType } from "@saleor/app-sdk/app-bridge"; import { ThemeType } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui"; import { useTheme } from "@saleor/macaw-ui";
import isEqualWith from "lodash/isEqualWith"; import isEqualWith from "lodash/isEqualWith";
@ -8,7 +9,7 @@ import React, { forwardRef, memo, useEffect, useRef } from "react";
interface AppIFrameProps { interface AppIFrameProps {
appId: string; appId: string;
src: string; src: string;
featureFlags: Record<string, string>; featureFlags: FlagList;
params: AppDetailsUrlQueryParams; params: AppDetailsUrlQueryParams;
onLoad: () => void; onLoad: () => void;
onError: () => void; onError: () => void;

View file

@ -7,6 +7,27 @@ import { renderHook } from "@testing-library/react-hooks";
import * as ReactIntl from "react-intl"; import * as ReactIntl from "react-intl";
import { IntlShape } 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 mockNotify = jest.fn();
const mockCloseExternalApp = jest.fn(); const mockCloseExternalApp = jest.fn();

View file

@ -2,6 +2,14 @@ import { AppUrls } from "@dashboard/apps/urls";
import * as config from "@dashboard/config"; import * as config from "@dashboard/config";
import { ThemeType } from "@saleor/app-sdk/app-bridge"; 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("AppUrls (apps/urls.ts)", () => {
describe("isAppDeepUrlChange", () => { describe("isAppDeepUrlChange", () => {
it("Returns true if only nested app path changes", () => { it("Returns true if only nested app path changes", () => {

View file

@ -1,5 +1,5 @@
import { getApiUrl } from "@dashboard/config"; import { getApiUrl } from "@dashboard/config";
import { FlagWithName } from "@dashboard/hooks/useFlags/types"; import { FlagList } from "@dashboard/featureFlags";
import { stringifyQs } from "@dashboard/utils/urls"; import { stringifyQs } from "@dashboard/utils/urls";
import { ThemeType } from "@saleor/app-sdk/app-bridge"; import { ThemeType } from "@saleor/app-sdk/app-bridge";
import urlJoin from "url-join"; import urlJoin from "url-join";
@ -24,7 +24,7 @@ export interface AppDetailsUrlMountQueryParams {
} }
interface FeatureFlagsQueryParams { interface FeatureFlagsQueryParams {
featureFlags?: Record<string, string>; featureFlags?: FlagList;
} }
export interface AppDetailsCommonParams { export interface AppDetailsCommonParams {
theme: ThemeType; theme: ThemeType;
@ -36,16 +36,6 @@ export type AppDetailsUrlQueryParams = Dialog<AppDetailsUrlDialog> &
export type AppInstallUrlQueryParams = Partial<{ [MANIFEST_ATTR]: string }>; export type AppInstallUrlQueryParams = Partial<{ [MANIFEST_ATTR]: string }>;
export const prepareFeatureFlagsList = (
flags: FlagWithName[],
): Record<string, string> =>
flags.reduce<Record<string, string>>((acc, flag) => {
if (flag.enabled) {
acc[flag.name] = `${flag.value || true}`;
}
return acc;
}, {});
export const AppSections = { export const AppSections = {
appsSection: "/apps/", appsSection: "/apps/",
}; };

View file

@ -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<FlagList | undefined>(undefined);
const [loading, setLoading] = useState(true);
const disableLoading = () => setLoading(false);
useEffect(() => {
resolver
.fetchAll()
.combineWithPriorities()
.then(setFlags)
.finally(disableLoading);
}, []);
return (
<Provider value={flags}>{loading ? <LoginLoading /> : children}</Provider>
);
};

View file

@ -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"}');
});
});

View file

@ -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);
}
}

View file

@ -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);
},
);
});

View file

@ -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<AvailableFlags.FlagList[]>;
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<AvailableFlags.FlagList> {
const list = await this.results;
return reduceFlagListArray(list);
}
public getResult() {
return this.results;
}
}
export const useFlagsResolver = (strategies: AvailableStrategies[]) => {
const resolver = useRef<FlagsResolver>(new FlagsResolver(strategies));
return resolver.current;
};

View file

@ -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);

View file

@ -0,0 +1,9 @@
import { FlagList } from "./availableFlags";
export interface Strategy {
fetchAll(): Promise<FlagList>;
}
export interface PersistableStrategy extends Strategy {
store?(flags: FlagList): Promise<void>;
}

View file

@ -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<Name, FlagValue>;
export type GeneralFlagList = TypedEntry extends never
? Record<string, FlagValue>
: 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],
}));

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
import { FlagList } from "./availableFlags";
const FeatureFlagsContext = createContext<FlagList | undefined>(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;
};

View file

@ -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";

View file

@ -0,0 +1,8 @@
import * as AvailableFlags from "../availableFlags";
import { Strategy } from "../Strategy";
export class DefaultsStrategy implements Strategy {
fetchAll(): Promise<AvailableFlags.FlagList> {
return Promise.resolve(AvailableFlags.asFlagValue());
}
}

View file

@ -0,0 +1,5 @@
import { ObjectStorageStrategy } from "./ObjectStorageStrategy";
export class EnvVarsStrategy extends ObjectStorageStrategy {
sourceObject = FLAGS;
}

View file

@ -0,0 +1,5 @@
import { ObjectStorageStrategy } from "./ObjectStorageStrategy";
export class LocalStorageStrategy extends ObjectStorageStrategy {
sourceObject = localStorage;
}

View file

@ -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<string, string> = {};
fetchAll(): Promise<FlagList> {
const result = Object.entries(this.sourceObject)
.filter(byFlagPrefix)
.reduce(toFlagList, {} as FlagList);
return Promise.resolve(result);
}
}

View file

@ -0,0 +1,6 @@
import { EnvVarsStrategy } from "./EnvVarsStrategy";
import { LocalStorageStrategy } from "./LocalStorageStrategy";
export { EnvVarsStrategy, LocalStorageStrategy };
export type AvailableStrategies = EnvVarsStrategy | LocalStorageStrategy;

View file

@ -0,0 +1,7 @@
import { useFeatureFlagContext } from "./context";
export const useAllFlags = () => {
const context = useFeatureFlagContext();
return context;
};

View file

@ -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];
};

View file

@ -0,0 +1,8 @@
import * as AvailableFlags from "./availableFlags";
import { useAllFlags } from "./useAllFlags";
export const useFlagsInfo = () => {
const allFlags = useAllFlags();
return AvailableFlags.asFlagInfoArray(allFlags);
};

View file

@ -1 +0,0 @@
export const ENV_FLAG_PREFIX = "FF_";

View file

@ -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<string, string> => {
if (FLAGS) {
return FLAGS;
}
return {};
};
export const isFlagEnabled = (flag: string) => flag !== "" && flag !== "false";

View file

@ -1,2 +0,0 @@
export { useEnvFlags } from "./useEnvFlags";
export { useAllEnvFlags } from "./useAllEnvFlags";

View file

@ -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",
},
]);
});
});

View file

@ -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,
}));
};

View file

@ -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",
},
});
});
});

View file

@ -1,23 +0,0 @@
import { FlagsResults } from "../types";
import { flagNameToEnvName, isFlagEnabled, readFlagFromEnv } from "./helpers";
export const useEnvFlags = <T extends readonly string[]>(
flags: readonly [...T],
): FlagsResults<T> =>
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<T>);

View file

@ -1,8 +0,0 @@
import React from "react";
interface FlagsServiceProviderProps {
children: React.ReactElement;
}
export const FlagsServiceProvider = ({ children }: FlagsServiceProviderProps) =>
children;

View file

@ -1,3 +0,0 @@
export { useServiceFlags } from "./useServiceFlags";
export { useAllServiceFlags } from "./useAllServiceFlags";
export { FlagsServiceProvider } from "./flagsServiceProvider";

View file

@ -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([]);
});
});

View file

@ -1,3 +0,0 @@
import { FlagWithName } from "../types";
export const useAllServiceFlags = (): FlagWithName[] => [];

View file

@ -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({});
});
});

View file

@ -1,13 +0,0 @@
import { FlagsResults } from "../types";
export const useServiceFlags = <T extends readonly string[]>(
flags: readonly [...T],
): FlagsResults<T> =>
flags.reduce((acc, flag) => {
acc[flag] = {
enabled: false,
value: "",
};
return acc;
}, {} as FlagsResults<T>);

View file

@ -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;

View file

@ -1,12 +0,0 @@
export interface Flag {
value: string;
enabled: boolean;
}
export interface FlagWithName extends Flag {
name: string;
}
export type FlagsResults<T extends readonly string[]> = {
[Key in T[number]]: Flag;
};

View file

@ -52,11 +52,15 @@ import CustomAppsSection from "./custom-apps";
import { CustomAppSections } from "./custom-apps/urls"; import { CustomAppSections } from "./custom-apps/urls";
import { CustomerSection } from "./customers"; import { CustomerSection } from "./customers";
import DiscountSection from "./discounts"; import DiscountSection from "./discounts";
import {
EnvVarsStrategy,
FeatureFlagsProvider,
LocalStorageStrategy,
} from "./featureFlags";
import GiftCardSection from "./giftCards"; import GiftCardSection from "./giftCards";
import { giftCardsSectionUrlName } from "./giftCards/urls"; import { giftCardsSectionUrlName } from "./giftCards/urls";
import { apolloClient, saleorClient } from "./graphql/client"; import { apolloClient, saleorClient } from "./graphql/client";
import HomePage from "./home"; import HomePage from "./home";
import { FlagsServiceProvider } from "./hooks/useFlags/flagsService";
import { useLocationState } from "./hooks/useLocationState"; import { useLocationState } from "./hooks/useLocationState";
import { commonMessages } from "./intl"; import { commonMessages } from "./intl";
import NavigationSection from "./navigation"; import NavigationSection from "./navigation";
@ -117,7 +121,12 @@ const App: React.FC = () => (
<ServiceWorker /> <ServiceWorker />
<BackgroundTasksProvider> <BackgroundTasksProvider>
<AppStateProvider> <AppStateProvider>
<FlagsServiceProvider> <FeatureFlagsProvider
strategies={[
new LocalStorageStrategy(),
new EnvVarsStrategy(),
]}
>
<AuthProvider> <AuthProvider>
<ShopProvider> <ShopProvider>
<AppChannelProvider> <AppChannelProvider>
@ -131,7 +140,7 @@ const App: React.FC = () => (
</AppChannelProvider> </AppChannelProvider>
</ShopProvider> </ShopProvider>
</AuthProvider> </AuthProvider>
</FlagsServiceProvider> </FeatureFlagsProvider>
</AppStateProvider> </AppStateProvider>
</BackgroundTasksProvider> </BackgroundTasksProvider>
</MessageManagerProvider> </MessageManagerProvider>

View file

@ -17,6 +17,14 @@ import ProductUpdatePage, { ProductUpdatePageProps } from "./ProductUpdatePage";
const product = productFixture(placeholderImage); 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 onSubmit = jest.fn();
const useNavigator = jest.spyOn(_useNavigator, "default"); const useNavigator = jest.spyOn(_useNavigator, "default");
jest.mock("@dashboard/components/RichTextEditor/RichTextEditor"); jest.mock("@dashboard/components/RichTextEditor/RichTextEditor");

1
types.d.ts vendored
View file

@ -6,7 +6,6 @@ declare module "*.svg" {
} }
declare const FLAGS_SERVICE_ENABLED: boolean; declare const FLAGS_SERVICE_ENABLED: boolean;
declare const FLAGSMITH_ID: string;
declare const FLAGS: Record<string, string>; declare const FLAGS: Record<string, string>;
declare interface Window { declare interface Window {

View file

@ -45,7 +45,6 @@ export default defineConfig(({ command, mode }) => {
DEMO_MODE, DEMO_MODE,
CUSTOM_VERSION, CUSTOM_VERSION,
FLAGS_SERVICE_ENABLED, FLAGS_SERVICE_ENABLED,
FLAGSMITH_ID,
} = env; } = env;
const base = STATIC_URL ?? "/"; const base = STATIC_URL ?? "/";
@ -132,7 +131,6 @@ export default defineConfig(({ command, mode }) => {
*/ */
...(isDev ? { global: {} } : {}), ...(isDev ? { global: {} } : {}),
FLAGS_SERVICE_ENABLED: FLAGS_SERVICE_ENABLED === "true", FLAGS_SERVICE_ENABLED: FLAGS_SERVICE_ENABLED === "true",
FLAGSMITH_ID: JSON.stringify(FLAGSMITH_ID),
// Keep all feature flags from env in global variable // Keep all feature flags from env in global variable
FLAGS: JSON.stringify(featureFlagsEnvs), FLAGS: JSON.stringify(featureFlagsEnvs),
}; };