Feature previews (#4079)
* Feature preview * Feature preview * Feature preview * Feature preview * Feature preview * Add visible flag * Typo * Typo * Fix re-login * Fix re-login * Fix enable * Fix enable * content * Fix types, overflow content * Fix user menu
This commit is contained in:
parent
e356674c76
commit
aaf646399e
30 changed files with 2748 additions and 433 deletions
19
.featureFlags/generated.tsx
Normal file
19
.featureFlags/generated.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import K35081 from "./images/filters.png"
|
||||
|
||||
const product_filters = () => (<><p><img src={K35081} alt="new filters"/></p>
|
||||
<p>Experience the new look and enhanced abilities of new fitering mechanism.
|
||||
Easily combine any criteria you want, and quickly browse their values.</p>
|
||||
</>)
|
||||
|
||||
export const AVAILABLE_FLAGS = [{
|
||||
name: "product_filters",
|
||||
displayName: "Products filtering",
|
||||
component: product_filters,
|
||||
visible: true,
|
||||
content: {
|
||||
enabled: false,
|
||||
payload: "default",
|
||||
}
|
||||
}] as const;
|
BIN
.featureFlags/images/filters.png
Normal file
BIN
.featureFlags/images/filters.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 71 KiB |
12
.featureFlags/product-filters.md
Normal file
12
.featureFlags/product-filters.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
name: product_filters
|
||||
displayName: Products filtering
|
||||
enabled: false
|
||||
payload: "default"
|
||||
visible: true
|
||||
---
|
||||
|
||||

|
||||
|
||||
Experience the new look and enhanced abilities of new fitering mechanism.
|
||||
Easily combine any criteria you want, and quickly browse their values.
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -53,3 +53,4 @@ django-queries-results.html
|
|||
|
||||
!.env.template
|
||||
!.dependency-cruiser.js
|
||||
!.featureFlags
|
||||
|
|
|
@ -27,6 +27,7 @@ export const MockedUserProvider: React.FC<{
|
|||
__typename: "User",
|
||||
accessibleChannels: [],
|
||||
restrictedAccessToChannels: false,
|
||||
metadata: []
|
||||
},
|
||||
errors: [],
|
||||
}}
|
||||
|
|
|
@ -758,6 +758,10 @@
|
|||
"context": "code ending with label",
|
||||
"string": "Code ending with {last4CodeChars}"
|
||||
},
|
||||
"38dc43": {
|
||||
"context": "Features preview",
|
||||
"string": "Features preview"
|
||||
},
|
||||
"39yi8w": {
|
||||
"context": "selected currency",
|
||||
"string": "Selected currency"
|
||||
|
@ -6844,6 +6848,9 @@
|
|||
"context": "Require email confirmation link",
|
||||
"string": "Require email confirmation link"
|
||||
},
|
||||
"krer6Z": {
|
||||
"string": "Features preview"
|
||||
},
|
||||
"kuo4fW": {
|
||||
"context": "dialog title",
|
||||
"string": "Capture manual transaction"
|
||||
|
@ -7408,6 +7415,9 @@
|
|||
"context": "gift card history message",
|
||||
"string": "Gift card was activated"
|
||||
},
|
||||
"pEf/m+": {
|
||||
"string": "There are no previews at the moment. Stay tuned!"
|
||||
},
|
||||
"pFVX6g": {
|
||||
"string": "Variant with these attributes already exists"
|
||||
},
|
||||
|
|
2076
package-lock.json
generated
2076
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -34,7 +34,7 @@
|
|||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"@material-ui/styles": "^4.11.4",
|
||||
"@reach/auto-id": "^0.16.0",
|
||||
"@saleor/macaw-ui": "0.8.0-pre.113",
|
||||
"@saleor/macaw-ui": "0.8.0-pre.119",
|
||||
"@saleor/sdk": "0.6.0",
|
||||
"@sentry/react": "^6.0.0",
|
||||
"@types/faker": "^5.1.6",
|
||||
|
@ -54,6 +54,7 @@
|
|||
"faker": "^5.1.0",
|
||||
"fast-array-diff": "^0.2.0",
|
||||
"find-test-names": "^1.17.1",
|
||||
"front-matter": "^4.0.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"fuzzaldrin": "^2.1.0",
|
||||
"graphiql": "^2.2.0",
|
||||
|
@ -89,6 +90,7 @@
|
|||
"react-router-dom": "^5.0.1",
|
||||
"react-sortable-hoc": "^1.10.1",
|
||||
"react-sortable-tree": "^2.6.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"slugify": "^1.4.6",
|
||||
"tslib": "^2.4.1",
|
||||
"url-join": "^4.0.1",
|
||||
|
@ -172,6 +174,7 @@
|
|||
"eslint-plugin-react-refresh": "^0.3.1",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"html-to-react": "^1.6.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"jscodeshift": "^0.13.0",
|
||||
|
@ -276,7 +279,7 @@
|
|||
"^@material-ui/core$": "<rootDir>/node_modules/@material-ui/core",
|
||||
"^@material-ui/icons$": "<rootDir>/node_modules/@material-ui/icons",
|
||||
"^@material-ui/styles$": "<rootDir>/node_modules/@material-ui/styles",
|
||||
"^@saleor/macaw-ui/next$": "<rootDir>/node_modules/@saleor/macaw-ui/dist/macaw-ui.js",
|
||||
"^@saleor/macaw-ui/next$": "<rootDir>/node_modules/@saleor/macaw-ui/dist/index.js",
|
||||
"^react$": "<rootDir>/node_modules/react",
|
||||
"^react-dom$": "<rootDir>/node_modules/react-dom"
|
||||
}
|
||||
|
@ -296,6 +299,7 @@
|
|||
"preview": "vite preview",
|
||||
"build-storybook": "cross-env NODE_OPTIONS=--max-old-space-size=6144 storybook build -o build/storybook",
|
||||
"build-types": "node scripts/build-types.js",
|
||||
"build-flags": "node scripts/build-flags.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 && tsc-strict",
|
||||
|
|
78
scripts/build-flags.js
Normal file
78
scripts/build-flags.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
const marked = require("marked")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const fm = require("front-matter")
|
||||
const HtmlToReactParser = require('html-to-react').Parser;
|
||||
const htmlToReactParser = new HtmlToReactParser();
|
||||
const ReactDOMServer = require('react-dom/server');
|
||||
|
||||
const imageImports = []
|
||||
|
||||
const generateImageComponentName = () => {
|
||||
const letter = String.fromCharCode(Math.floor(Math.random() * 26 + 97)).toUpperCase()
|
||||
const name = String(Math.random()).substring(2, 7)
|
||||
|
||||
return `${letter}${name}`
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
image(href, _, text) {
|
||||
const componentName = generateImageComponentName()
|
||||
imageImports.push({ componentName, href })
|
||||
|
||||
return `<img src=${componentName} alt="${text}"/>`;
|
||||
}
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
|
||||
|
||||
const mdParsed = fs
|
||||
.readdirSync(path.resolve(__dirname, "./../.featureFlags"))
|
||||
.filter(f => f.endsWith(".md"))
|
||||
.map(file => fs.readFileSync(path.resolve(__dirname, "./../.featureFlags/", file)))
|
||||
.map(binary => binary.toString())
|
||||
.map(fm)
|
||||
.map(({ body, attributes }) => ({
|
||||
component: htmlToReactParser.parse(marked.parse(body)),
|
||||
attributes
|
||||
}))
|
||||
.map(({ component, attributes }) => ({
|
||||
component: ReactDOMServer.renderToStaticMarkup(component),
|
||||
...attributes
|
||||
}))
|
||||
|
||||
|
||||
const availableFilters = mdParsed
|
||||
.reduce((prev, content) => prev.concat(content), [])
|
||||
.map(el =>
|
||||
`{
|
||||
name: "${el.name}",
|
||||
displayName: "${el.displayName}",
|
||||
component: ${el.name},
|
||||
visible: ${el.visible},
|
||||
content: {
|
||||
enabled: ${el.enabled},
|
||||
payload: "${el.payload}",
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
const componentsAsTemplate = mdParsed.reduce(
|
||||
(prev, { name, component }) => `${prev}\nconst ${name} = () => (<>${component}</>)`,
|
||||
"")
|
||||
|
||||
const components = imageImports.reduce((p, c) => {
|
||||
return p.replace(`"${c.componentName}"`, `{${c.componentName}}`)
|
||||
}, componentsAsTemplate)
|
||||
|
||||
const imports = imageImports.reduce((p, c) => `${p}\nimport ${c.componentName} from "${c.href}"`, "")
|
||||
|
||||
const template = `// @ts-nocheck
|
||||
${imports}
|
||||
${components}
|
||||
|
||||
export const AVAILABLE_FLAGS = [${availableFilters}] as const;
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.resolve(__dirname, "./../.featureFlags/generated.tsx"), template)
|
|
@ -1,4 +1,3 @@
|
|||
// @ts-strict-ignore
|
||||
import { useUser } from "@dashboard/auth";
|
||||
import { staffMemberDetailsUrl } from "@dashboard/staff/urls";
|
||||
import { useTheme } from "@dashboard/theme";
|
||||
|
@ -16,6 +15,7 @@ import React, { useEffect, useState } from "react";
|
|||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { FeatureFlagsModal } from "./FeatureFlagsModal";
|
||||
import { ThemeSwitcher } from "./ThemeSwitcher";
|
||||
|
||||
export const useLegacyThemeHandler = () => {
|
||||
|
@ -28,11 +28,11 @@ export const useLegacyThemeHandler = () => {
|
|||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (!["macaw-ui-theme", "activeMacawUITheme"].includes(event.key)) {
|
||||
if (!["macaw-ui-theme", "activeMacawUITheme"].includes(event.key || "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDark = event.newValue.toLowerCase().includes("dark");
|
||||
const isDark = event.newValue?.toLowerCase().includes("dark");
|
||||
setLegacyTheme(isDark ? "dark" : "light");
|
||||
setTheme(isDark ? "defaultDark" : "defaultLight");
|
||||
};
|
||||
|
@ -50,8 +50,10 @@ export const UserControls = () => {
|
|||
const { user, logout } = useUser();
|
||||
const { changeTheme, theme } = useLegacyThemeHandler();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [flagsModalOpen, setFlagsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={value => {
|
||||
|
@ -82,7 +84,7 @@ export const UserControls = () => {
|
|||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Link
|
||||
to={staffMemberDetailsUrl(user?.id)}
|
||||
to={staffMemberDetailsUrl(user?.id || "")}
|
||||
className={sprinkles({
|
||||
display: "block",
|
||||
width: "100%",
|
||||
|
@ -98,6 +100,20 @@ export const UserControls = () => {
|
|||
</Link>
|
||||
</List.Item>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<List.Item
|
||||
{...listItemStyles}
|
||||
onClick={() => setFlagsModalOpen(true)}
|
||||
>
|
||||
<Text>
|
||||
<FormattedMessage
|
||||
id="38dc43"
|
||||
defaultMessage="Features preview"
|
||||
description="Features preview"
|
||||
/>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<List.Item
|
||||
onClick={logout}
|
||||
|
@ -133,6 +149,8 @@ export const UserControls = () => {
|
|||
</Box>
|
||||
</Dropdown.Content>
|
||||
</Dropdown>
|
||||
<FeatureFlagsModal open={flagsModalOpen} onChange={setFlagsModalOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
42
src/components/Sidebar/user/FeatureFlagsModal/Content.tsx
Normal file
42
src/components/Sidebar/user/FeatureFlagsModal/Content.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Name } from "@dashboard/featureFlags/availableFlags";
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import React from "react";
|
||||
|
||||
import { ToggleButton } from "./ToggleButton";
|
||||
|
||||
interface ContentProps {
|
||||
flagName: string;
|
||||
flagSlug: Name;
|
||||
component: () => JSX.Element;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export const Content = ({
|
||||
flagName,
|
||||
flagSlug,
|
||||
component,
|
||||
isEnabled,
|
||||
}: ContentProps) => {
|
||||
const TabComponent = component;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderLeftWidth={0}
|
||||
backgroundColor="surfaceNeutralPlain"
|
||||
borderColor="neutralHighlight"
|
||||
width="100%"
|
||||
overflowY="scroll"
|
||||
padding={3}
|
||||
>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Text variant="heading">{flagName}</Text>
|
||||
<ToggleButton isEnabled={isEnabled} flagSlug={flagSlug} />
|
||||
</Box>
|
||||
<Box fontSize="bodyMedium" paddingBottom={12}>
|
||||
<TabComponent />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
47
src/components/Sidebar/user/FeatureFlagsModal/FlagList.tsx
Normal file
47
src/components/Sidebar/user/FeatureFlagsModal/FlagList.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Name } from "@dashboard/featureFlags/availableFlags";
|
||||
import { useFlagsInfo } from "@dashboard/featureFlags/useFlagsInfo";
|
||||
import { List, Text } from "@saleor/macaw-ui/next";
|
||||
import React from "react";
|
||||
|
||||
interface FlagListProps {
|
||||
selectedName: string;
|
||||
onItemClick: (name: Name) => void;
|
||||
}
|
||||
|
||||
export const FlagList = ({ selectedName, onItemClick }: FlagListProps) => {
|
||||
const flags = useFlagsInfo();
|
||||
|
||||
return (
|
||||
<List
|
||||
__width="40%"
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderBottomWidth={0}
|
||||
borderColor="neutralHighlight"
|
||||
overflowY="scroll"
|
||||
>
|
||||
{flags
|
||||
.filter(flag => flag.visible)
|
||||
.map(flag => (
|
||||
<List.Item
|
||||
key={flag.name}
|
||||
paddingX={3}
|
||||
paddingY={4}
|
||||
backgroundColor={{
|
||||
default:
|
||||
selectedName === flag.name
|
||||
? "surfaceNeutralHighlight"
|
||||
: "surfaceNeutralSubdued",
|
||||
hover: "surfaceNeutralHighlight",
|
||||
}}
|
||||
borderColor="neutralHighlight"
|
||||
borderBottomWidth={1}
|
||||
borderBottomStyle="solid"
|
||||
onClick={() => onItemClick(flag.name)}
|
||||
>
|
||||
<Text variant="body">{flag.displayName}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
16
src/components/Sidebar/user/FeatureFlagsModal/Header.tsx
Normal file
16
src/components/Sidebar/user/FeatureFlagsModal/Header.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import React, { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface HeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Header = ({ children }: HeaderProps) => (
|
||||
<Box display="flex" justifyContent="space-between" paddingX={3} paddingY={4}>
|
||||
<Text variant="heading">
|
||||
<FormattedMessage defaultMessage="Features preview" id="krer6Z" />
|
||||
</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
23
src/components/Sidebar/user/FeatureFlagsModal/NoFlags.tsx
Normal file
23
src/components/Sidebar/user/FeatureFlagsModal/NoFlags.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export const NoFlags = () => (
|
||||
<Box
|
||||
width="100%"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderTopWidth={1}
|
||||
borderColor="neutralHighlight"
|
||||
paddingTop={52}
|
||||
>
|
||||
<Text color="textNeutralDisabled" size="large">
|
||||
<FormattedMessage
|
||||
id="pEf/m+"
|
||||
defaultMessage="There are no previews at the moment. Stay tuned!"
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
|
@ -0,0 +1,36 @@
|
|||
import { Name } from "@dashboard/featureFlags/availableFlags";
|
||||
import { Button, Spinner } from "@saleor/macaw-ui/next";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { usePersistence } from "./usePersistence";
|
||||
|
||||
interface ToggleButtonProps {
|
||||
isEnabled: boolean;
|
||||
flagSlug: Name;
|
||||
}
|
||||
|
||||
export const ToggleButton = ({ isEnabled, flagSlug }: ToggleButtonProps) => {
|
||||
const { toggleFlag } = usePersistence();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleToggleClick = async () => {
|
||||
setLoading(true);
|
||||
await toggleFlag(flagSlug);
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Button variant="secondary" size="small" disabled>
|
||||
<Spinner /> {isEnabled ? "Disabling" : "Enabling"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="secondary" size="small" onClick={handleToggleClick}>
|
||||
{isEnabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
);
|
||||
};
|
72
src/components/Sidebar/user/FeatureFlagsModal/index.tsx
Normal file
72
src/components/Sidebar/user/FeatureFlagsModal/index.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { Name } from "@dashboard/featureFlags/availableFlags";
|
||||
import { Box, Button, CloseIcon, Modal } from "@saleor/macaw-ui/next";
|
||||
import React from "react";
|
||||
|
||||
import { Content } from "./Content";
|
||||
import { FlagList } from "./FlagList";
|
||||
import { Header } from "./Header";
|
||||
import { NoFlags } from "./NoFlags";
|
||||
import { useFlagsState } from "./useFlagsState";
|
||||
|
||||
interface FeatureFlagsModalProps {
|
||||
open: boolean;
|
||||
onChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FeatureFlagsModal = ({
|
||||
open,
|
||||
onChange,
|
||||
}: FeatureFlagsModalProps) => {
|
||||
const { selectedFlag, hasNoFlags, changeTab } = useFlagsState();
|
||||
|
||||
const handleTabClick = (tabName: Name) => {
|
||||
changeTab(tabName);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onChange={onChange}>
|
||||
<Modal.Content>
|
||||
<Box
|
||||
backgroundColor="surfaceNeutralPlain"
|
||||
borderRadius={3}
|
||||
position="fixed"
|
||||
boxShadow="modal"
|
||||
overflow="hidden"
|
||||
__left="50%"
|
||||
__top="50%"
|
||||
__width="960px"
|
||||
__height="600px"
|
||||
__transform="translate(-50%, -50%)"
|
||||
>
|
||||
<Header>
|
||||
<Modal.Close>
|
||||
<Button variant="tertiary" icon={<CloseIcon />} size="small" />
|
||||
</Modal.Close>
|
||||
</Header>
|
||||
<Box
|
||||
display="flex"
|
||||
backgroundColor="surfaceNeutralPlain"
|
||||
height="100%"
|
||||
>
|
||||
{hasNoFlags ? (
|
||||
<NoFlags />
|
||||
) : (
|
||||
<>
|
||||
<FlagList
|
||||
selectedName={selectedFlag.name}
|
||||
onItemClick={handleTabClick}
|
||||
/>
|
||||
<Content
|
||||
flagName={selectedFlag.displayName}
|
||||
flagSlug={selectedFlag.name as Name}
|
||||
component={selectedFlag.component}
|
||||
isEnabled={selectedFlag.content.enabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import { FlagDefinition, Name } from "@dashboard/featureFlags/availableFlags";
|
||||
import { useFlagsInfo } from "@dashboard/featureFlags/useFlagsInfo";
|
||||
import { useState } from "react";
|
||||
|
||||
type changeTab = (name: Name) => void;
|
||||
|
||||
type FlagsState =
|
||||
| { hasNoFlags: false; selectedFlag: FlagDefinition; changeTab: changeTab }
|
||||
| { hasNoFlags: true; selectedFlag: undefined; changeTab: changeTab };
|
||||
|
||||
export const useFlagsState = (): FlagsState => {
|
||||
const flagsInfo = useFlagsInfo();
|
||||
const flags = flagsInfo.filter(f => f.visible);
|
||||
const firstFlag = flags.length > 0 ? flags[0].name : null;
|
||||
const [tab, changeTab] = useState(firstFlag);
|
||||
const selectedFlag = flags.find(f => f.name === tab);
|
||||
|
||||
if (!selectedFlag) {
|
||||
return {
|
||||
hasNoFlags: true,
|
||||
selectedFlag: undefined,
|
||||
changeTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasNoFlags: false,
|
||||
selectedFlag,
|
||||
changeTab,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
flagInfoToFlagList,
|
||||
Name,
|
||||
} from "@dashboard/featureFlags/availableFlags";
|
||||
import { flagListToMetadata } from "@dashboard/featureFlags/strategies/MetadataStrategy";
|
||||
import { useFlagsInfo } from "@dashboard/featureFlags/useFlagsInfo";
|
||||
import { useUserAccountUpdateMutation } from "@dashboard/graphql";
|
||||
|
||||
export const usePersistence = () => {
|
||||
const flags = useFlagsInfo();
|
||||
const [updateAccount] = useUserAccountUpdateMutation();
|
||||
|
||||
const toggleFlag = async (flagName: Name) => {
|
||||
const flagList = flagInfoToFlagList(flags);
|
||||
flagList[flagName].enabled = !flagList[flagName].enabled;
|
||||
const metadata = flagListToMetadata(flagList);
|
||||
|
||||
await updateAccount({
|
||||
variables: { input: { metadata } },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
toggleFlag,
|
||||
};
|
||||
};
|
|
@ -26,6 +26,7 @@ const user = {
|
|||
userPermissions: staffMember.userPermissions,
|
||||
restrictedAccessToChannels: false,
|
||||
accessibleChannels: [],
|
||||
metadata: [],
|
||||
};
|
||||
|
||||
const versions = {
|
||||
|
|
|
@ -1,35 +1,65 @@
|
|||
import { useUser } from "@dashboard/auth";
|
||||
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";
|
||||
import { FlagsResolver } from "./FlagsResolver";
|
||||
import {
|
||||
AvailableStrategies,
|
||||
EnvVarsStrategy,
|
||||
LocalStorageStrategy,
|
||||
} from "./strategies";
|
||||
import { MetadataStrategy } from "./strategies/MetadataStrategy";
|
||||
|
||||
interface FeatureFlagsProviderProps {
|
||||
children: ReactNode;
|
||||
strategies: AvailableStrategies[];
|
||||
deps?: unknown[];
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const resolver = new FlagsResolver(strategies);
|
||||
|
||||
resolver
|
||||
.fetchAll()
|
||||
.combineWithPriorities()
|
||||
.then(setFlags)
|
||||
.finally(disableLoading);
|
||||
}, []);
|
||||
}, [strategies]);
|
||||
|
||||
return (
|
||||
<Provider value={flags}>{loading ? <LoginLoading /> : children}</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeatureFlagsProviderWithUserProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const FeatureFlagsProviderWithUser = ({
|
||||
children,
|
||||
}: FeatureFlagsProviderWithUserProps) => {
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<FeatureFlagsProvider
|
||||
strategies={[
|
||||
new LocalStorageStrategy(),
|
||||
new EnvVarsStrategy(),
|
||||
new MetadataStrategy(user.user?.metadata || []),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagsProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
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";
|
||||
|
@ -35,9 +32,3 @@ export class FlagsResolver {
|
|||
return this.results;
|
||||
}
|
||||
}
|
||||
|
||||
export const useFlagsResolver = (strategies: AvailableStrategies[]) => {
|
||||
const resolver = useRef<FlagsResolver>(new FlagsResolver(strategies));
|
||||
|
||||
return resolver.current;
|
||||
};
|
||||
|
|
|
@ -1,35 +1,18 @@
|
|||
import { AVAILABLE_FLAGS as GENERATED_FLAGS } from "./../../.featureFlags/generated";
|
||||
import { FlagValue } from "./FlagContent";
|
||||
|
||||
interface FlagDefinition {
|
||||
export interface FlagDefinition {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
component: () => JSX.Element;
|
||||
visible: boolean;
|
||||
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: "product_filters",
|
||||
displayName: "Product filters",
|
||||
description: "New filters on product listing page",
|
||||
content: { enabled: false, payload: "" },
|
||||
} as const,
|
||||
] satisfies FlagDefinition[];
|
||||
export const AVAILABLE_FLAGS = GENERATED_FLAGS;
|
||||
|
||||
type TypedEntry = (typeof AVAILABLE_FLAGS)[number];
|
||||
type GeneralEntry = TypedEntry extends never ? FlagDefinition : TypedEntry;
|
||||
|
@ -58,3 +41,14 @@ export const asFlagInfoArray = (list: GeneralFlagList) =>
|
|||
...el,
|
||||
content: list[el.name],
|
||||
}));
|
||||
|
||||
export const flagInfoToFlagList = (flagInfos: FlagDefinition[]): FlagList => {
|
||||
return flagInfos.reduce((prev, curr) => {
|
||||
prev[curr.name as Name] = new FlagValue(
|
||||
curr.content.enabled,
|
||||
curr.content.payload,
|
||||
);
|
||||
|
||||
return prev;
|
||||
}, {} as FlagList);
|
||||
};
|
||||
|
|
41
src/featureFlags/strategies/MetadataStrategy.ts
Normal file
41
src/featureFlags/strategies/MetadataStrategy.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { MetadataItemFragment } from "@dashboard/graphql";
|
||||
|
||||
import { asFlagValue, FlagList } from "../availableFlags";
|
||||
import * as AvailableFlags from "../availableFlags";
|
||||
import { Strategy } from "../Strategy";
|
||||
|
||||
const METADATA_KEY = "feature_flags";
|
||||
|
||||
export const flagListToMetadata = (
|
||||
list: FlagList,
|
||||
): Array<Omit<MetadataItemFragment, "__typename">> => {
|
||||
return [{ key: METADATA_KEY, value: JSON.stringify(list) }];
|
||||
};
|
||||
|
||||
export const flagListFromMetadata = (
|
||||
metadata: Array<Omit<MetadataItemFragment, "__typename">>,
|
||||
): FlagList => {
|
||||
const defaultsList = asFlagValue();
|
||||
const rawFlags = metadata.find(x => x.key === METADATA_KEY);
|
||||
const flagList = rawFlags
|
||||
? JSON.parse(rawFlags.value)
|
||||
: (defaultsList as FlagList);
|
||||
|
||||
return Object.keys(defaultsList).reduce((list: FlagList, key: string) => {
|
||||
list[key as AvailableFlags.Name] = defaultsList[key as AvailableFlags.Name];
|
||||
|
||||
if (flagList[key]) {
|
||||
list[key as AvailableFlags.Name] = flagList[key];
|
||||
}
|
||||
|
||||
return list;
|
||||
}, {} as FlagList);
|
||||
};
|
||||
|
||||
export class MetadataStrategy implements Strategy {
|
||||
constructor(public metadata: MetadataItemFragment[]) {}
|
||||
|
||||
fetchAll(): Promise<AvailableFlags.FlagList> {
|
||||
return Promise.resolve(flagListFromMetadata(this.metadata));
|
||||
}
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
import { EnvVarsStrategy } from "./EnvVarsStrategy";
|
||||
import { LocalStorageStrategy } from "./LocalStorageStrategy";
|
||||
import { MetadataStrategy } from "./MetadataStrategy";
|
||||
|
||||
export { EnvVarsStrategy, LocalStorageStrategy };
|
||||
export { EnvVarsStrategy, LocalStorageStrategy, MetadataStrategy };
|
||||
|
||||
export type AvailableStrategies = EnvVarsStrategy | LocalStorageStrategy;
|
||||
export type AvailableStrategies =
|
||||
| EnvVarsStrategy
|
||||
| LocalStorageStrategy
|
||||
| MetadataStrategy;
|
||||
|
|
|
@ -23,6 +23,10 @@ export const fragmentUser = gql`
|
|||
firstName
|
||||
lastName
|
||||
isStaff
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
userPermissions {
|
||||
...UserPermission
|
||||
}
|
||||
|
|
|
@ -24,6 +24,10 @@ export const staffMemberDetailsFragment = gql`
|
|||
avatar(size: 512) {
|
||||
url
|
||||
}
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
@ -202,6 +202,10 @@ export const UserFragmentDoc = gql`
|
|||
firstName
|
||||
lastName
|
||||
isStaff
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
userPermissions {
|
||||
...UserPermission
|
||||
}
|
||||
|
@ -2758,6 +2762,10 @@ export const StaffMemberDetailsFragmentDoc = gql`
|
|||
avatar(size: 512) {
|
||||
url
|
||||
}
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
${StaffMemberFragmentDoc}`;
|
||||
export const CountryFragmentDoc = gql`
|
||||
|
|
|
@ -8375,7 +8375,7 @@ export type AvailableExternalAuthenticationsQuery = { __typename: 'Query', shop:
|
|||
export type UserDetailsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type UserDetailsQuery = { __typename: 'Query', me: { __typename: 'User', id: string, email: string, firstName: string, lastName: string, isStaff: boolean, restrictedAccessToChannels: boolean, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, accessibleChannels: Array<{ __typename: 'Channel', id: string, isActive: boolean, name: string, slug: string, currencyCode: string, defaultCountry: { __typename: 'CountryDisplay', code: string, country: string }, stockSettings: { __typename: 'StockSettings', allocationStrategy: AllocationStrategyEnum } }> | null } | null };
|
||||
export type UserDetailsQuery = { __typename: 'Query', me: { __typename: 'User', id: string, email: string, firstName: string, lastName: string, isStaff: boolean, restrictedAccessToChannels: boolean, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, accessibleChannels: Array<{ __typename: 'Channel', id: string, isActive: boolean, name: string, slug: string, currencyCode: string, defaultCountry: { __typename: 'CountryDisplay', code: string, country: string }, stockSettings: { __typename: 'StockSettings', allocationStrategy: AllocationStrategyEnum } }> | null } | null };
|
||||
|
||||
export type CategoryDeleteMutationVariables = Exact<{
|
||||
id: Scalars['ID'];
|
||||
|
@ -9092,7 +9092,7 @@ export type UserPermissionFragment = { __typename: 'UserPermission', code: Permi
|
|||
|
||||
export type UserUserPermissionWithSourcePermissionGroupsFragment = { __typename: 'UserPermission', code: PermissionEnum, name: string, sourcePermissionGroups: Array<{ __typename: 'Group', id: string }> | null };
|
||||
|
||||
export type UserFragment = { __typename: 'User', id: string, email: string, firstName: string, lastName: string, isStaff: boolean, restrictedAccessToChannels: boolean, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, accessibleChannels: Array<{ __typename: 'Channel', id: string, isActive: boolean, name: string, slug: string, currencyCode: string, defaultCountry: { __typename: 'CountryDisplay', code: string, country: string }, stockSettings: { __typename: 'StockSettings', allocationStrategy: AllocationStrategyEnum } }> | null };
|
||||
export type UserFragment = { __typename: 'User', id: string, email: string, firstName: string, lastName: string, isStaff: boolean, restrictedAccessToChannels: boolean, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, accessibleChannels: Array<{ __typename: 'Channel', id: string, isActive: boolean, name: string, slug: string, currencyCode: string, defaultCountry: { __typename: 'CountryDisplay', code: string, country: string }, stockSettings: { __typename: 'StockSettings', allocationStrategy: AllocationStrategyEnum } }> | null };
|
||||
|
||||
export type UserBaseFragment = { __typename: 'User', id: string, firstName: string, lastName: string };
|
||||
|
||||
|
@ -9488,7 +9488,7 @@ export type ShopFragment = { __typename: 'Shop', customerSetPasswordUrl: string
|
|||
|
||||
export type StaffMemberFragment = { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string };
|
||||
|
||||
export type StaffMemberDetailsFragment = { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null };
|
||||
export type StaffMemberDetailsFragment = { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> };
|
||||
|
||||
export type StaffMemberAvatarFragment = { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, avatar: { __typename: 'Image', url: string } | null };
|
||||
|
||||
|
@ -11095,7 +11095,7 @@ export type StaffMemberAddMutationVariables = Exact<{
|
|||
}>;
|
||||
|
||||
|
||||
export type StaffMemberAddMutation = { __typename: 'Mutation', staffCreate: { __typename: 'StaffCreate', errors: Array<{ __typename: 'StaffError', code: AccountErrorCode, field: string | null, message: string | null }>, user: { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null } | null } | null };
|
||||
export type StaffMemberAddMutation = { __typename: 'Mutation', staffCreate: { __typename: 'StaffCreate', errors: Array<{ __typename: 'StaffError', code: AccountErrorCode, field: string | null, message: string | null }>, user: { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null } | null };
|
||||
|
||||
export type StaffMemberUpdateMutationVariables = Exact<{
|
||||
id: Scalars['ID'];
|
||||
|
@ -11103,7 +11103,7 @@ export type StaffMemberUpdateMutationVariables = Exact<{
|
|||
}>;
|
||||
|
||||
|
||||
export type StaffMemberUpdateMutation = { __typename: 'Mutation', staffUpdate: { __typename: 'StaffUpdate', errors: Array<{ __typename: 'StaffError', code: AccountErrorCode, field: string | null, message: string | null }>, user: { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null } | null } | null };
|
||||
export type StaffMemberUpdateMutation = { __typename: 'Mutation', staffUpdate: { __typename: 'StaffUpdate', errors: Array<{ __typename: 'StaffError', code: AccountErrorCode, field: string | null, message: string | null }>, user: { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null } | null };
|
||||
|
||||
export type UserPassowrdChangeMutationVariables = Exact<{
|
||||
newPassword: Scalars['String'];
|
||||
|
@ -11164,7 +11164,7 @@ export type StaffMemberDetailsQueryVariables = Exact<{
|
|||
}>;
|
||||
|
||||
|
||||
export type StaffMemberDetailsQuery = { __typename: 'Query', user: { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null } | null };
|
||||
export type StaffMemberDetailsQuery = { __typename: 'Query', user: { __typename: 'User', id: string, email: string, firstName: string, isActive: boolean, lastName: string, permissionGroups: Array<{ __typename: 'Group', id: string, name: string, userCanManage: boolean }> | null, userPermissions: Array<{ __typename: 'UserPermission', code: PermissionEnum, name: string }> | null, avatar: { __typename: 'Image', url: string } | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null };
|
||||
|
||||
export type TaxConfigurationUpdateMutationVariables = Exact<{
|
||||
id: Scalars['ID'];
|
||||
|
|
|
@ -52,11 +52,7 @@ 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 { FeatureFlagsProviderWithUser } from "./featureFlags/FeatureFlagsProvider";
|
||||
import GiftCardSection from "./giftCards";
|
||||
import { giftCardsSectionUrlName } from "./giftCards/urls";
|
||||
import { apolloClient, saleorClient } from "./graphql/client";
|
||||
|
@ -121,12 +117,6 @@ const App: React.FC = () => (
|
|||
<ServiceWorker />
|
||||
<BackgroundTasksProvider>
|
||||
<AppStateProvider>
|
||||
<FeatureFlagsProvider
|
||||
strategies={[
|
||||
new LocalStorageStrategy(),
|
||||
new EnvVarsStrategy(),
|
||||
]}
|
||||
>
|
||||
<AuthProvider>
|
||||
<ShopProvider>
|
||||
<AppChannelProvider>
|
||||
|
@ -140,7 +130,6 @@ const App: React.FC = () => (
|
|||
</AppChannelProvider>
|
||||
</ShopProvider>
|
||||
</AuthProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</AppStateProvider>
|
||||
</BackgroundTasksProvider>
|
||||
</MessageManagerProvider>
|
||||
|
@ -173,6 +162,7 @@ const Routes: React.FC = () => {
|
|||
<WindowTitle title={intl.formatMessage(commonMessages.dashboard)} />
|
||||
{DEMO_MODE && <DemoBanner />}
|
||||
{homePageLoaded ? (
|
||||
<FeatureFlagsProviderWithUser>
|
||||
<AppLayout fullSize={isAppPath}>
|
||||
<ErrorBoundary
|
||||
onError={e => {
|
||||
|
@ -322,6 +312,7 @@ const Routes: React.FC = () => {
|
|||
</Switch>
|
||||
</ErrorBoundary>
|
||||
</AppLayout>
|
||||
</FeatureFlagsProviderWithUser>
|
||||
) : homePageLoading ? (
|
||||
<LoginLoading />
|
||||
) : (
|
||||
|
|
|
@ -155,6 +155,7 @@ export const staffMember: StaffMemberDetailsFragment = {
|
|||
...p,
|
||||
__typename: "UserPermission",
|
||||
})),
|
||||
metadata: [],
|
||||
};
|
||||
|
||||
export const staffMemberAvatar: StaffMemberAvatarFragment = {
|
||||
|
|
Loading…
Reference in a new issue