Feature flags (#2961)

* [Feature Flags] Abstraction over flags provider (#2928)

* Remove useFlag hook

* [Feature Flags] GraphQL build multiple schemas (#2937)

* Build script

* Refactor build types script

* Remove old codegen.yml

* Clean feature flags in script

* Refactor schema path

* Restore useAuthProvider

* Update configuration file

* encapsulate details for feature flags provider

* Add proper env to flagsmith provider

* Remove flagsmith mocks

* Vite config define global variables

* Render flagmisth provider only when is used

* Keep name service agnostic

* Test with mocked flagsmith

* Use global FLAGS varaible for env flags

* Fix type issue with FLAGS

* Fix build issue

* Remove duplicate translations

* Fix typo

* Prepare for QA tests

* Remove test feature flag
This commit is contained in:
poulch 2023-01-16 14:55:38 +01:00 committed by GitHub
parent 7a43421af5
commit d5ed6fb202
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 755 additions and 147 deletions

View file

@ -1,53 +0,0 @@
overwrite: true
schema: ./introspection.json
generates:
./src/graphql/fragmentTypes.generated.ts:
plugins:
- add:
content: /* eslint-disable */
- fragment-matcher
config:
minify: false
apolloClientVersion: 3
./src/graphql/typePolicies.generated.ts:
plugins:
- add:
content: /* eslint-disable */
- typescript-apollo-client-helpers
./src/graphql/types.generated.ts:
documents:
- ./src/**/queries.ts
- ./src/**/mutations.ts
- ./src/**/fragments/*.ts
- ./src/searches/*.ts
config:
nonOptionalTypename: true
avoidOptionals:
field: true
inputValue: false
object: false
defaultValue: false
namingConvention:
enumValues: change-case-all#upperCase
onlyOperationTypes: true
plugins:
- add:
content: /* eslint-disable */
- typescript
- typescript-operations
./src/graphql/hooks.generated.ts:
documents:
- ./src/**/queries.ts
- ./src/**/mutations.ts
- ./src/**/fragments/*.ts
- ./src/searches/*.ts
preset: import-types
presetConfig:
typesPath: ./types.generated
config:
withHooks: true
apolloReactHooksImportFrom: "@dashboard/hooks/graphql"
plugins:
- add:
content: /* eslint-disable */
- typescript-react-apollo

View file

@ -85,8 +85,16 @@ describe("As a staff user I want to manage apps", () => {
});
})
.then(({ variantsList: variants }) => {
variantsList = variants
cy.checkIfDataAreNotNull({createdApp,defaultChannel,address,warehouse,shippingMethod,variantsList,checkout})
variantsList = variants;
cy.checkIfDataAreNotNull({
createdApp,
defaultChannel,
address,
warehouse,
shippingMethod,
variantsList,
checkout,
});
});
});

View file

@ -71,8 +71,14 @@ describe("As an admin I want to manage collections.", () => {
},
)
.then(({ product: productResp }) => {
product = productResp
cy.checkIfDataAreNotNull({attribute, category, productType, product, defaultChannel})
product = productResp;
cy.checkIfDataAreNotNull({
attribute,
category,
productType,
product,
defaultChannel,
});
});
});

View file

@ -79,7 +79,15 @@ describe("As a customer I should be able to purchase gift card as a product", ()
})
.then(({ variantsList: variantsResp }) => {
variants = variantsResp;
cy.checkIfDataAreNotNull({defaultChannel, productType, attribute, category, shippingMethod, variants, address})
cy.checkIfDataAreNotNull({
defaultChannel,
productType,
attribute,
category,
shippingMethod,
variants,
address,
});
});
});

View file

@ -83,7 +83,14 @@ describe("Warehouses in checkout", () => {
})
.then(({ variantsList }) => {
variantsInOtherWarehouse = variantsList;
cy.checkIfDataAreNotNull({defaultChannel, usAddress, secondUsAddress, productData, checkoutData, variantsInOtherWarehouse})
cy.checkIfDataAreNotNull({
defaultChannel,
usAddress,
secondUsAddress,
productData,
checkoutData,
variantsInOtherWarehouse,
});
});
});

View file

@ -83,12 +83,17 @@ describe("Products without shipment option", () => {
name: nameProdWithoutShipping,
productTypeId: productTypeResp.id,
warehouseId: warehouse.id,
}).then(
({ variantsList }) => {
productWithoutShipping = variantsList
cy.checkIfDataAreNotNull({channel, address, warehouse, shippingMethod, productWithShipping, productWithoutShipping})
}
);
}).then(({ variantsList }) => {
productWithoutShipping = variantsList;
cy.checkIfDataAreNotNull({
channel,
address,
warehouse,
shippingMethod,
productWithShipping,
productWithoutShipping,
});
});
},
);
});

View file

@ -56,7 +56,14 @@ describe("Manage products stocks in checkout", () => {
lastVariantInStock = resp.createdVariants.find(
variant => variant.name === "lastVariantInStock",
);
cy.checkIfDataAreNotNull({defaultChannel, address,shippingMethod, variantsWithLowStock, variantsWithoutTrackInventory, lastVariantInStock})
cy.checkIfDataAreNotNull({
defaultChannel,
address,
shippingMethod,
variantsWithLowStock,
variantsWithoutTrackInventory,
lastVariantInStock,
});
});
});

View file

@ -32,7 +32,7 @@ describe("As an admin I want to use attributes in variant selection", () => {
getDefaultChannel().then(defaultChannel => (channel = defaultChannel));
createCategory({ name: startsWith }).then(
categoryResp => (category = categoryResp),
cy.checkIfDataAreNotNull({channel, category})
cy.checkIfDataAreNotNull({ channel, category }),
);
});

View file

@ -29,7 +29,7 @@ describe("As an admin I want to delete and update content attribute", () => {
}).then(attributeResp => {
attribute = attributeResp;
});
cy.checkIfDataAreNotNull(attribute)
cy.checkIfDataAreNotNull(attribute);
});
it(

View file

@ -24,12 +24,10 @@ describe("Tests for menu navigation", () => {
before(() => {
cy.clearSessionData().loginUserViaRequest();
deleteMenusStartsWith(startsWith);
createMenuViaApi(randomName).then(
({ menu: menuResp }) => {
menu = menuResp
cy.checkIfDataAreNotNull(menu)
},
);
createMenuViaApi(randomName).then(({ menu: menuResp }) => {
menu = menuResp;
cy.checkIfDataAreNotNull(menu);
});
});
beforeEach(() => {

View file

@ -92,8 +92,17 @@ describe("Adyen payments", () => {
});
})
.then(({ variantsList: variants }) => {
variantsList = variants
cy.checkIfDataAreNotNull({address,defaultChannel,warehouse,shippingMethod,variantsList,checkout,paymentCards,cardData})
variantsList = variants;
cy.checkIfDataAreNotNull({
address,
defaultChannel,
warehouse,
shippingMethod,
variantsList,
checkout,
paymentCards,
cardData,
});
});
});

View file

@ -50,7 +50,15 @@ describe("Stripe payments", () => {
defaultChannel = values.defaultChannel;
shippingMethod = values.shippingMethod;
variantsList = values.variantsList;
cy.checkIfDataAreNotNull({address,defaultChannel,shippingMethod,variantsList,checkout,paymentCards,cardData})
cy.checkIfDataAreNotNull({
address,
defaultChannel,
shippingMethod,
variantsList,
checkout,
paymentCards,
cardData,
});
});
});

View file

@ -30,8 +30,8 @@ describe("As an admin I want to manage product types", () => {
createAttribute({ name: startsWith }).then(resp => (attribute = resp));
createCategory({ name: startsWith }).then(resp => (category = resp));
getDefaultChannel().then(resp => {
channel = resp
cy.checkIfDataAreNotNull({attribute, channel, category})
channel = resp;
cy.checkIfDataAreNotNull({ attribute, channel, category });
});
});

View file

@ -95,7 +95,14 @@ describe("As a staff user I want to create shipping zone and rate", () => {
})
.then(variantsListResp => {
secondVariantsList = variantsListResp;
cy.checkIfDataAreNotNull({defaultChannel,address,warehouse,variantsList, secondVariantsList, attribute})
cy.checkIfDataAreNotNull({
defaultChannel,
address,
warehouse,
variantsList,
secondVariantsList,
attribute,
});
});
});

View file

@ -50,7 +50,13 @@ describe("As a user I should be able to update and delete shipping method", () =
createShippingZone(name, "US", defaultChannel.id, warehouse.id).then(
shippingZoneResp => {
shippingZone = shippingZoneResp;
cy.checkIfDataAreNotNull({defaultChannel, shippingZone, shippingMethod, warehouse, usAddress})
cy.checkIfDataAreNotNull({
defaultChannel,
shippingZone,
shippingMethod,
warehouse,
usAddress,
});
},
);
});

View file

@ -40,13 +40,17 @@ describe("As a user I should be able to update and delete shipping zone", () =>
warehouse = warehouseResp;
updateChannelWarehouses(defaultChannel.id, warehouse.id);
cy.checkIfDataAreNotNull({defaultChannel, shippingZone, plAddress, warehouse})
cy.checkIfDataAreNotNull({
defaultChannel,
shippingZone,
plAddress,
warehouse,
});
});
});
});
beforeEach(() => {
cy.clearSessionData().loginUserViaRequest();
createShippingZone(name, "US", defaultChannel.id, warehouse.id).then(
shippingZoneResp => {

View file

@ -83,7 +83,14 @@ describe("As a user I want to create shipping method with postal codes", () => {
})
.then(({ variantsList: variantsListResp }) => {
variantsList = variantsListResp;
cy.checkIfDataAreNotNull({defaultChannel,usAddress,secondUsAddress,shippingZone,warehouse,variantsList})
cy.checkIfDataAreNotNull({
defaultChannel,
usAddress,
secondUsAddress,
shippingZone,
warehouse,
variantsList,
});
});
});

View file

@ -74,7 +74,13 @@ describe("As a staff user I want to manage shipping weights", () => {
})
.then(({ variantsList: variantsListResp }) => {
variantsList = variantsListResp;
cy.checkIfDataAreNotNull({defaultChannel,usAddress,shippingZone,warehouse,variantsList})
cy.checkIfDataAreNotNull({
defaultChannel,
usAddress,
shippingZone,
warehouse,
variantsList,
});
});
});

View file

@ -51,7 +51,12 @@ describe("As a staff user I want to change shop default weight unit", () => {
})
.then(shippingZoneResp => {
shippingZone = shippingZoneResp;
cy.checkIfDataAreNotNull({defaultChannel,usAddress,shippingZone,warehouse})
cy.checkIfDataAreNotNull({
defaultChannel,
usAddress,
shippingZone,
warehouse,
});
});
});

View file

@ -17,7 +17,7 @@ describe("Tests for site settings", () => {
cy.fixture("addresses").then(({ usAddress, plAddress }) => {
address = usAddress;
updateShopAddress(plAddress);
cy.checkIfDataAreNotNull(address)
cy.checkIfDataAreNotNull(address);
});
});

View file

@ -35,7 +35,7 @@ describe("As an admin I want to manage warehouses", () => {
cy.fixture("addresses").then(addresses => {
usAddress = addresses.usAddress;
secondUsAddress = addresses.secondUsAddress;
cy.checkIfDataAreNotNull({usAddress,secondUsAddress})
cy.checkIfDataAreNotNull({ usAddress, secondUsAddress });
});
});

View file

@ -25,7 +25,7 @@ describe("Tests for customer registration", () => {
deleteCustomersStartsWith(startsWith);
getDefaultChannel().then(channel => {
defaultChannel = channel;
cy.checkIfDataAreNotNull({defaultChannel})
cy.checkIfDataAreNotNull({ defaultChannel });
});
});

View file

@ -69,7 +69,13 @@ describe("As an admin I want to create sale for products", () => {
})
.then(({ warehouse: warehouseResp }) => {
warehouse = warehouseResp;
cy.checkIfDataAreNotNull({productType,attribute,category,defaultChannel,warehouse})
cy.checkIfDataAreNotNull({
productType,
attribute,
category,
defaultChannel,
warehouse,
});
});
});

View file

@ -71,7 +71,12 @@ describe("Sales discounts for variant", () => {
warehouseId: warehouse.id,
price: productPrice,
};
cy.checkIfDataAreNotNull({productData, defaultChannel, warehouse, address})
cy.checkIfDataAreNotNull({
productData,
defaultChannel,
warehouse,
address,
});
});
});

View file

@ -82,7 +82,14 @@ describe("As an admin I want to update sales", () => {
warehouseId: warehouse.id,
price: productPrice,
};
cy.checkIfDataAreNotNull({productData, defaultChannel, warehouse, address, sale, variants})
cy.checkIfDataAreNotNull({
productData,
defaultChannel,
warehouse,
address,
sale,
variants,
});
});
});

View file

@ -53,7 +53,11 @@ describe("As an admin I want to create voucher", () => {
auth: "token",
};
},
cy.checkIfDataAreNotNull({createdChannel, dataForCheckout, defaultChannel})
cy.checkIfDataAreNotNull({
createdChannel,
dataForCheckout,
defaultChannel,
}),
);
});

View file

@ -49,7 +49,7 @@ describe("As an admin I want to create voucher", () => {
shippingMethodName: shippingMethodResp.name,
auth: "token",
};
cy.checkIfDataAreNotNull({dataForCheckout, defaultChannel})
cy.checkIfDataAreNotNull({ dataForCheckout, defaultChannel });
cy.clearSessionData();
},
);

View file

@ -51,7 +51,7 @@ describe("As an admin I want to update vouchers", () => {
auth: "token",
};
},
cy.checkIfDataAreNotNull({dataForCheckout, defaultChannel, product})
cy.checkIfDataAreNotNull({ dataForCheckout, defaultChannel, product }),
);
});

View file

@ -41,7 +41,7 @@ describe("Test for metadata", () => {
})
.then(({ product: productResp }) => {
product = productResp;
cy.checkIfDataAreNotNull({channel, product})
cy.checkIfDataAreNotNull({ channel, product });
});
});

View file

@ -67,7 +67,15 @@ describe("As an admin I want to manage stock reservation", () => {
productType = productTypeResp;
},
);
cy.checkIfDataAreNotNull({defaultChannel,address,warehouse,attribute,category,productType,dataForCheckout})
cy.checkIfDataAreNotNull({
defaultChannel,
address,
warehouse,
attribute,
category,
productType,
dataForCheckout,
});
});
});
});

View file

@ -105,7 +105,14 @@ describe("Orders", () => {
)
.then(({ variantsList: variantsResp }) => {
variantsList = variantsResp;
cy.checkIfDataAreNotNull({customer,defaultChannel,warehouse,shippingMethod,variantsList,address})
cy.checkIfDataAreNotNull({
customer,
defaultChannel,
warehouse,
shippingMethod,
variantsList,
address,
});
});
});

View file

@ -55,7 +55,7 @@ describe("As an admin I should be able to create product", () => {
cy.clearSessionData().loginUserViaRequest();
createAttribute({ name }).then(attributeResp => {
attribute = attributeResp;
cy.checkIfDataAreNotNull({attribute})
cy.checkIfDataAreNotNull({ attribute });
});
});
beforeEach(() => {

View file

@ -27,7 +27,7 @@ describe("As an admin I should be able to sort products", () => {
cy.clearSessionData().loginUserViaRequest();
getDefaultChannel().then(channel => {
defaultChannel = channel;
cy.checkIfDataAreNotNull({defaultChannel})
cy.checkIfDataAreNotNull({ defaultChannel });
});
});

View file

@ -49,8 +49,15 @@ describe("As an admin I should be able to create variant", () => {
createChannel({ isActive: true, name, currencyCode: "PLN" });
})
.then(resp => {
newChannel = resp
cy.checkIfDataAreNotNull({defaultChannel,warehouse,attribute,productType,category,newChannel})
newChannel = resp;
cy.checkIfDataAreNotNull({
defaultChannel,
warehouse,
attribute,
productType,
category,
newChannel,
});
});
});

View file

@ -52,7 +52,7 @@ describe("Staff members", () => {
cy.clearSessionData().visit(urlLink);
fillUpSetPassword(password);
cy.clearSessionData();
cy.checkIfDataAreNotNull({user})
cy.checkIfDataAreNotNull({ user });
});
});

View file

@ -20,12 +20,10 @@ describe("As an admin I want to manage translations", () => {
before(() => {
cy.clearSessionData().loginUserViaRequest();
deleteCategoriesStartsWith(startsWith);
createCategory({ name: startsWith }).then(
categoryResp => {
category = categoryResp
cy.checkIfDataAreNotNull({category})
},
);
createCategory({ name: startsWith }).then(categoryResp => {
category = categoryResp;
cy.checkIfDataAreNotNull({ category });
});
});
beforeEach(() => {

View file

@ -1,5 +1,5 @@
Cypress.Commands.add("getTextFromElement", element =>
cy.get(element).invoke("text")
cy.get(element).invoke("text"),
);
Cypress.Commands.add("clearAndType", { prevSubject: true }, (subject, text) => {
@ -12,22 +12,22 @@ Cypress.Commands.add("waitForRequestAndCheckIfNoErrors", alias => {
cy.wait(alias).then(resp => {
expect(
resp.response.body.errors,
`No errors in ${alias} operation in graphql response`
`No errors in ${alias} operation in graphql response`,
).to.be.undefined;
return resp;
});
});
Cypress.Commands.add("checkIfDataAreNotNull", (data) => {
Cypress.Commands.add("checkIfDataAreNotNull", data => {
expect(data, "Created data should not be null").to.be.not.null;
if (typeof data === "object") {
Object.keys(data).forEach(key => {
cy.checkIfDataAreNotNull(data[key])
})
cy.checkIfDataAreNotNull(data[key]);
});
} else if (Array.isArray(data)) {
expect(data).not.to.be.empty;
data.forEach(singleData => {
cy.checkIfDataAreNotNull(singleData)
})
cy.checkIfDataAreNotNull(singleData);
});
}
})
});

View file

@ -23,3 +23,9 @@ Create `.env` file in a root directory or set environment variables with the fol
- `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

11
package-lock.json generated
View file

@ -40,6 +40,7 @@
"faker": "^5.1.0",
"fast-array-diff": "^0.2.0",
"find-test-names": "^1.17.1",
"flagsmith": "^3.15.1",
"fuse.js": "^6.6.2",
"fuzzaldrin": "^2.1.0",
"graphql": "^15.4.0",
@ -21046,6 +21047,11 @@
"resolved": "https://registry.npmjs.org/first-match/-/first-match-0.0.1.tgz",
"integrity": "sha1-pg7GQnAPD0NyNOu37D84JHblQv0="
},
"node_modules/flagsmith": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/flagsmith/-/flagsmith-3.15.1.tgz",
"integrity": "sha512-4OHVCCYuW1doYceEJbrQWJgefwRaC9oi2VeswVzCqCe0WCoT0pOIxgi14l3Am0a0fcN9wUd93Dv2OQaF6GqKjA=="
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
@ -59661,6 +59667,11 @@
"resolved": "https://registry.npmjs.org/first-match/-/first-match-0.0.1.tgz",
"integrity": "sha1-pg7GQnAPD0NyNOu37D84JHblQv0="
},
"flagsmith": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/flagsmith/-/flagsmith-3.15.1.tgz",
"integrity": "sha512-4OHVCCYuW1doYceEJbrQWJgefwRaC9oi2VeswVzCqCe0WCoT0pOIxgi14l3Am0a0fcN9wUd93Dv2OQaF6GqKjA=="
},
"flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",

View file

@ -47,6 +47,7 @@
"faker": "^5.1.0",
"fast-array-diff": "^0.2.0",
"find-test-names": "^1.17.1",
"flagsmith": "^3.15.1",
"fuse.js": "^6.6.2",
"fuzzaldrin": "^2.1.0",
"graphql": "^15.4.0",
@ -212,6 +213,10 @@
},
"jest": {
"resetMocks": false,
"globals": {
"FLAGS_SERVICE_ENABLED": false,
"FLAGS": {}
},
"setupFilesAfterEnv": [
"jest-canvas-mock",
"jest-localstorage-mock",
@ -262,7 +267,7 @@
"build": "cross-env NODE_OPTIONS=--max_old_space_size=16384 vite build",
"preview": "vite preview",
"build-storybook": "cross-env NODE_OPTIONS=--openssl-legacy-provider build-storybook -c src/storybook/ -o build/storybook",
"build-types": "graphql-codegen",
"build-types": "node scripts/build-types.js",
"prebuild": "npm run build-types",
"check-strict-null-errors": "tsc --noEmit --strictNullChecks | node scripts/count-strict-null-check-errors.js",
"check-types": "tsc --noEmit",

110
scripts/build-types.js Normal file
View file

@ -0,0 +1,110 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { generate } = require("@graphql-codegen/cli");
// Feature flags names that will be used as suffix for generated files
const FEATURE_FLAGS = [];
const schemaSuffixes = ["default", ...FEATURE_FLAGS];
for (const rawSuffix of schemaSuffixes) {
const suffix = prepareSuffix(rawSuffix);
generate(
{
schema: `./introspection${suffix}.json`,
overwrite: true,
generates: {
[getOutputPath("fragmentTypes", suffix)]: {
plugins: [
{
add: {
content: "/* eslint-disable */",
},
},
"fragment-matcher",
],
config: {
minify: false,
apolloClientVersion: 3,
},
},
[getOutputPath("typePolicies", suffix)]: {
plugins: [
{
add: {
content: "/* eslint-disable */",
},
},
"typescript-apollo-client-helpers",
],
},
[getOutputPath("types", suffix)]: {
documents: getDocumentsPaths(suffix),
config: {
nonOptionalTypename: true,
avoidOptionals: {
field: true,
inputValue: false,
object: false,
defaultValue: false,
},
namingConvention: {
enumValues: "change-case-all#upperCase",
},
onlyOperationTypes: true,
},
plugins: [
{
add: {
content: "/* eslint-disable */",
},
},
"typescript",
"typescript-operations",
],
},
[getOutputPath("hooks", suffix)]: {
documents: getDocumentsPaths(suffix),
preset: "import-types",
presetConfig: {
typesPath: "./types" + suffix + ".generated",
},
config: {
withHooks: true,
apolloReactHooksImportFrom: "@dashboard/hooks/graphql",
},
plugins: [
{
add: {
content: "/* eslint-disable */",
},
},
"typescript-react-apollo",
],
},
},
},
true,
);
}
function getOutputPath(path, suffix) {
return `./src/graphql/${path}${suffix}.generated.ts`;
}
function getDocumentsPaths(suffix) {
return [
`./src/**/queries${suffix}.ts`,
`./src/**/mutations${suffix}.ts`,
`./src/**/fragments/*${suffix}.ts`,
`./src/searches/*${suffix}.ts`,
];
}
function prepareSuffix(suffix) {
if (suffix === "default") {
return "";
}
return "." + suffix;
}

View file

@ -1,8 +1,10 @@
import {
AppDetailsUrlQueryParams,
getAppDeepPathFromDashboardUrl,
prepareFeatureFlagsList,
resolveAppIframeUrl,
} from "@dashboard/apps/urls";
import { useAllFlags } from "@dashboard/hooks/useFlags";
import useLocale from "@dashboard/hooks/useLocale";
import useShop from "@dashboard/hooks/useShop";
import { useTheme } from "@saleor/macaw-ui";
@ -42,6 +44,7 @@ export const AppFrame: React.FC<Props> = ({
const { themeType } = useTheme();
const classes = useStyles();
const appOrigin = getOrigin(src);
const flags = useAllFlags();
const { postToExtension } = useAppActions(frameRef, appOrigin, appId);
const location = useLocation();
const { locale } = useLocale();
@ -102,7 +105,10 @@ export const AppFrame: React.FC<Props> = ({
return (
<iframe
ref={frameRef}
src={resolveAppIframeUrl(appId, src, params)}
src={resolveAppIframeUrl(appId, src, {
...params,
featureFlags: prepareFeatureFlagsList(flags),
})}
onError={onError}
onLoad={handleLoad}
className={clsx(classes.iframe, className)}

View file

@ -1,4 +1,5 @@
import { getApiUrl } from "@dashboard/config";
import { FlagWithName } from "@dashboard/hooks/useFlags/types";
import { stringifyQs } from "@dashboard/utils/urls";
import urlJoin from "url-join";
@ -26,9 +27,14 @@ export interface AppDetailsUrlMountQueryParams {
customerIds?: string[];
}
interface FeatureFlagsQueryParams {
featureFlags?: Record<string, string>;
}
export type AppDetailsUrlQueryParams = Dialog<AppDetailsUrlDialog> &
SingleAction &
AppDetailsUrlMountQueryParams;
AppDetailsUrlMountQueryParams &
FeatureFlagsQueryParams;
export type AppInstallUrlQueryParams = Partial<{ [MANIFEST_ATTR]: string }>;
@ -130,3 +136,13 @@ export const resolveAppIframeUrl = (
return urlJoin(appUrl, window.location.search, iframeContextQueryString);
};
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;
}, {});

1
src/hooks/useFlags/env/const.ts vendored Normal file
View file

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

32
src/hooks/useFlags/env/helpers.ts vendored Normal file
View file

@ -0,0 +1,32 @@
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 {};
};

2
src/hooks/useFlags/env/index.ts vendored Normal file
View file

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

View file

@ -0,0 +1,40 @@
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([]);
});
});

View file

@ -0,0 +1,12 @@
import { FlagWithName } from "../types";
import { envNameToFlagName, readAllFlagsFromEnv } from "./helpers";
export const useAllEnvFlags = (): FlagWithName[] => {
const flags = readAllFlagsFromEnv();
return Object.entries(flags).map(([flagKey, flagValue]) => ({
name: envNameToFlagName(flagKey),
enabled: flagValue !== "",
value: flagValue,
}));
};

View file

@ -0,0 +1,57 @@
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({});
});
});

23
src/hooks/useFlags/env/useEnvFlags.ts vendored Normal file
View file

@ -0,0 +1,23 @@
import { FlagsResults } from "../types";
import { flagNameToEnvName, 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: envFlag !== "",
value: envFlag,
};
} else {
acc[flag] = {
enabled: false,
value: "",
};
}
return acc;
}, {} as FlagsResults<T>);

View file

@ -0,0 +1,27 @@
import flagsmith from "flagsmith";
import { FlagsmithProvider as Provider } from "flagsmith/react";
import React from "react";
interface FlagsServiceProviderProps {
children: React.ReactElement;
}
export const FlagsServiceProvider = ({
children,
}: FlagsServiceProviderProps) => {
if (!FLAGS_SERVICE_ENABLED) {
return children;
}
return (
<Provider
flagsmith={flagsmith}
options={{
environmentID: FLAGSMITH_ID,
cacheFlags: true,
}}
>
{children}
</Provider>
);
};

View file

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

View file

@ -0,0 +1,44 @@
import { renderHook } from "@testing-library/react-hooks";
import { useAllServiceFlags } from "./useAllServiceFlags";
jest.mock("flagsmith/react", () => ({
__esModule: true,
useFlagsmith: () => ({
getAllFlags: () => ({
flag_one: {
enabled: true,
value: "1",
},
flag_two: {
enabled: true,
value: "2",
},
}),
}),
}));
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([
{
name: "flagOne",
enabled: true,
value: "1",
},
{
name: "flagTwo",
enabled: true,
value: "2",
},
]);
});
});

View file

@ -0,0 +1,15 @@
import { useFlagsmith } from "flagsmith/react";
import camelCase from "lodash/camelCase";
import { FlagWithName } from "../types";
export const useAllServiceFlags = (): FlagWithName[] => {
const flagsmith = useFlagsmith();
const flags = flagsmith.getAllFlags();
return Object.entries(flags).map(([name, { value, enabled }]) => ({
name: camelCase(name),
value: value ?? "",
enabled,
}));
};

View file

@ -0,0 +1,50 @@
import { renderHook } from "@testing-library/react-hooks";
import { useServiceFlags } from "./useServiceFlags";
jest.mock("flagsmith/react", () => ({
__esModule: true,
useFlags: () => ({
flag_one: {
enabled: true,
value: "1",
},
flag_two: {
enabled: true,
value: "2",
},
}),
}));
afterAll(() => {
jest.clearAllMocks();
});
describe("useServiceFlags", () => {
test("should return flags with values", () => {
// Arrange && Ac
const { result } = renderHook(() =>
useServiceFlags(["flagOne", "flag_two"]),
);
// Assert
expect(result.current).toEqual({
flagOne: {
enabled: true,
value: "1",
},
flag_two: {
enabled: true,
value: "2",
},
});
});
test("should return empty object when not flags provided", () => {
// Arrange && Act
const { result } = renderHook(() => useServiceFlags([]));
// Assert
expect(result.current).toEqual({});
});
});

View file

@ -0,0 +1,27 @@
import { useFlags } from "flagsmith/react";
import snakeCase from "lodash/snakeCase";
import { FlagsResults } from "../types";
export const useServiceFlags = <T extends readonly string[]>(
flags: readonly [...T],
traits?: string[],
): FlagsResults<T> => {
const flagsmithFlags = useFlags(transformFlagsToSnakeCase(flags), traits);
return flags.reduce((acc, flag) => {
const flagName = snakeCase(flag);
if (flagsmithFlags[flagName]) {
acc[flag] = {
enabled: flagsmithFlags[flagName].enabled,
value: flagsmithFlags[flagName].value ?? "",
};
}
return acc;
}, {} as FlagsResults<T>);
};
function transformFlagsToSnakeCase(flags: readonly string[]): string[] {
return flags.map(flag => snakeCase(flag));
}

View file

@ -0,0 +1,7 @@
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

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

View file

@ -50,6 +50,7 @@ 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 { commonMessages } from "./intl";
import MarketplaceSection from "./marketplace";
import { marketplaceUrl } from "./marketplace/urls";
@ -92,6 +93,7 @@ const App: React.FC = () => (
<ServiceWorker />
<BackgroundTasksProvider>
<AppStateProvider>
<FlagsServiceProvider>
<AuthProvider>
<ShopProvider>
<AppChannelProvider>
@ -103,6 +105,7 @@ const App: React.FC = () => (
</AppChannelProvider>
</ShopProvider>
</AuthProvider>
</FlagsServiceProvider>
</AppStateProvider>
</BackgroundTasksProvider>
</MessageManagerProvider>

View file

@ -1,5 +1,6 @@
import { ExternalAppProvider } from "@dashboard/apps/components/ExternalAppContext";
import { Locale, RawLocaleProvider } from "@dashboard/components/Locale";
import { FlagsServiceProvider } from "@dashboard/hooks/useFlags/flagsService";
import themeOverrides from "@dashboard/themeOverrides";
import { ThemeProvider } from "@saleor/macaw-ui";
import React from "react";
@ -26,6 +27,7 @@ export const Decorator = storyFn => (
<ThemeProvider overrides={themeOverrides}>
<BrowserRouter basename={getAppMountUri()}>
<ExternalAppProvider>
<FlagsServiceProvider>
<MessageManagerProvider>
<div
style={{
@ -35,6 +37,7 @@ export const Decorator = storyFn => (
{storyFn()}
</div>
</MessageManagerProvider>
</FlagsServiceProvider>
</ExternalAppProvider>
</BrowserRouter>
</ThemeProvider>

4
types.d.ts vendored
View file

@ -5,6 +5,10 @@ declare module "*.svg" {
export default content;
}
declare const FLAGS_SERVICE_ENABLED: boolean;
declare const FLAGSMITH_ID: string;
declare const FLAGS: Record<string, string>;
declare interface Window {
PasswordCredential: PasswordCredential;
__SALEOR_CONFIG__: {

View file

@ -35,8 +35,14 @@ export default defineConfig(({ command, mode }) => {
APPS_TUNNEL_URL_KEYWORDS,
SKIP_SOURCEMAPS,
DEMO_MODE,
FLAGS_SERVICE_ENABLED,
FLAGSMITH_ID,
} = env;
const featureFlagsEnvs = Object.fromEntries(
Object.entries(env).filter(([flagKey]) => flagKey.startsWith("FF_")),
);
const sourcemap = SKIP_SOURCEMAPS ? false : true;
const enableSentry =
@ -96,11 +102,17 @@ export default defineConfig(({ command, mode }) => {
);
}
const globals = {
/*
"qs" package uses 'get-intrinsic' whish refers to the global object, we need to recreate it.
Issue presents only on development mode.
*/
const globals = isDev ? { global: {} } : {};
...(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),
};
return {
root: "src",