From 27988e4b302bfddc906b2dcfde10148b6dbd280d Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Mon, 27 Sep 2021 13:35:32 +0200 Subject: [PATCH] Feat/environment strategies (#339) * feat: strategies list * feat: dnd * fix: resolve reference issues * feat: configure strategy wip * feat: rearrange list * feat: add debounce and execution plan * feat: add separator * feat: update strategy * fix: feature strategy accordion key * fix: localize parameter update logic * feat: ts conversion * fix: perf issues * feat: production guard * fix: clean up environment list * fix: implement markup hooks for environment list * feat: wip constraints * fix: handle nested data structure reference issue * fix: clone deep on child props * fix: remove constraints check * fix: revert to strategies length * fix: refactor useFeature * feat: cache revalidation * fix: set correct starting tab * fix: reset params on adding new strategy * fix: refactor to use useSWR instead of local cache * fix: check dirty directly from new params * fix: dialogue ts * fix: Clean-up typescript warnings * fix: some more typescript nits * feat: strategy execution * feat: strategy execution for environment * fix: refactor execution separator * fix: remove unused property * fix: add header * fix: 0 value for rollout * fix: update snapshots * fix: remove empty deps * fix: use constant for env type * fix: use default for useFeatureStrategy * fix: update snapshot * Update src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDeleteStrategyMarkup.tsx Co-authored-by: Christopher Kolstad * Update src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.tsx Co-authored-by: Christopher Kolstad * Update src/component/feature/strategy/EditStrategyModal/general-strategy.jsx Co-authored-by: Christopher Kolstad Co-authored-by: Christopher Kolstad Co-authored-by: UnleashTeam <79193084+UnleashTeam@users.noreply.github.com> --- frontend/src/assets/icons/gradual.svg | 17 + .../common/AnimateOnMount/AnimateOnMount.tsx | 3 + ...{Dialogue.styles.js => Dialogue.styles.ts} | 4 +- .../Dialogue/{Dialogue.jsx => Dialogue.tsx} | 35 +- .../PercentageCircle/PercentageCircle.tsx | 39 +++ .../src/component/common/TabNav/TabNav.jsx | 11 +- .../common/{select.jsx => select.tsx} | 44 ++- .../FeatureStrategies.styles.ts | 5 + .../FeatureStrategies/FeatureStrategies.tsx | 19 ++ ...tureEnvironmentStrategyExecution.styles.ts | 20 ++ .../FeatureEnvironmentStrategyExecution.tsx | 45 +++ ...onmentStrategyExecutionSeparator.styles.ts | 21 ++ ...eEnvironmentStrategyExecutionSeparator.tsx | 18 ++ ...ureEnvironmentStrategyExecutionWrapper.tsx | 49 +++ .../FeatureStrategiesConfigure.styles.ts | 28 ++ .../FeatureStrategiesConfigure.tsx | 169 ++++++++++ ...FeatureStrategiesEnvironmentList.styles.ts | 40 +++ .../FeatureStrategiesEnvironmentList.tsx | 166 ++++++++++ .../useDeleteStrategyMarkup.tsx | 32 ++ .../useDropboxMarkup.tsx | 30 ++ .../useFeatureStrategiesEnvironmentList.ts | 136 ++++++++ .../useProductionGuardMarkup.tsx | 24 ++ .../FeatureStrategiesEnvironments.styles.ts | 44 +++ .../FeatureStrategiesEnvironments.tsx | 301 ++++++++++++++++++ .../FeatureStrategiesProductionGuard.tsx | 38 +++ .../FeatureStrategiesRefresh.styles.ts | 41 +++ .../FeatureStrategiesRefresh.tsx | 52 +++ .../FeatureStrategiesSeparator.tsx | 32 ++ .../FeatureStrategyEditable.styles.ts | 23 ++ .../FeatureStrategyEditable.tsx | 182 +++++++++++ .../FeatureStrategiesList.styles.ts | 13 + .../FeatureStrategiesList.tsx | 35 ++ .../FeatureStrategyCard.styles.ts | 47 +++ .../FeatureStrategyCard.tsx | 68 ++++ .../FeatureStrategiesUIProvider.tsx | 40 +++ .../FeatureStrategyAccordion.styles.ts | 46 +++ .../FeatureStrategyAccordion.tsx | 86 +++++ .../FeatureStrategyAccordionBody.styles.ts | 31 ++ .../FeatureStrategyAccordionBody.tsx | 182 +++++++++++ .../FeatureStrategyCreateExecution.styles.ts | 20 ++ .../FeatureStrategyCreateExecution.tsx | 26 ++ .../FeatureStrategyExecution.styles.ts | 34 ++ .../FeatureStrategyExecution.tsx | 120 +++++++ .../FeatureStrategyExecutionChips.styles.ts | 11 + .../FeatureStrategyExecutionChips.tsx | 41 +++ ...atureStrategyExecutionConstraint.styles.ts | 27 ++ .../FeatureStrategyExecutionConstraint.tsx | 32 ++ .../FlexibleStrategy/FlexibleStrategy.tsx | 130 ++++++++ .../GeneralStrategy/GeneralStrategy.styles.ts | 13 + .../GeneralStrategy/GeneralStrategy.tsx | 167 ++++++++++ .../common/RolloutSlider/RolloutSlider.tsx | 111 +++++++ .../StrategyConstraints.tsx | 125 ++++++++ .../StrategyInputList/StrategyInputList.tsx | 111 +++++++ .../common/UserWithIdStrategy/UserWithId.tsx | 34 ++ .../FeatureView2/FeatureView2.styles.ts | 28 ++ .../feature/FeatureView2/FeatureView2.tsx | 88 ++++- .../FeatureViewEnvironment.styles.ts | 2 +- .../FeatureViewEnvironment.tsx | 2 +- .../FeatureViewMetadata.styles.ts | 2 +- .../feature/strategy/AddStrategy/utils.js | 2 +- .../EditStrategyModal/general-strategy.jsx | 221 +++++++------ .../update-variant-component-test.jsx.snap | 1 - .../view-component-test.jsx.snap | 1 - .../__snapshots__/routes-test.jsx.snap | 10 + .../component/menu/__tests__/routes-test.jsx | 2 +- frontend/src/component/menu/routes.js | 11 + frontend/src/constants/environmentTypes.ts | 1 + .../contexts/FeatureStrategiesUIContext.ts | 21 ++ .../useFeatureStrategyApi.ts | 85 +++++ .../api/getters/useFeature/useFeature.ts | 32 +- .../useFeatureStrategy/useFeatureStrategy.ts | 67 ++++ .../getters/useStrategies/useStrategies.ts | 40 +++ .../useUnleashContext/useUnleashContext.ts | 40 +++ frontend/src/hooks/useTabs.ts | 14 + frontend/src/hooks/useToast.tsx | 10 +- frontend/src/interfaces/featureToggle.ts | 4 +- frontend/src/interfaces/params.ts | 4 + frontend/src/interfaces/strategy.ts | 20 +- frontend/src/themes/main-theme.js | 2 + frontend/src/utils/strategy-names.js | 36 ++- frontend/yarn.lock | 13 +- 81 files changed, 3801 insertions(+), 176 deletions(-) create mode 100644 frontend/src/assets/icons/gradual.svg rename frontend/src/component/common/Dialogue/{Dialogue.styles.js => Dialogue.styles.ts} (75%) rename frontend/src/component/common/Dialogue/{Dialogue.jsx => Dialogue.tsx} (79%) create mode 100644 frontend/src/component/common/PercentageCircle/PercentageCircle.tsx rename frontend/src/component/common/{select.jsx => select.tsx} (60%) create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionWrapper/FeatureEnvironmentStrategyExecutionWrapper.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDeleteStrategyMarkup.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDropboxMarkup.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useProductionGuardMarkup.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesSeparator/FeatureStrategiesSeparator.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesUIProvider.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/FlexibleStrategy/FlexibleStrategy.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.styles.ts create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/RolloutSlider/RolloutSlider.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyConstraints/StrategyConstraints.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyInputList/StrategyInputList.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureStrategies/common/UserWithIdStrategy/UserWithId.tsx create mode 100644 frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts create mode 100644 frontend/src/constants/environmentTypes.ts create mode 100644 frontend/src/contexts/FeatureStrategiesUIContext.ts create mode 100644 frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts create mode 100644 frontend/src/hooks/api/getters/useFeatureStrategy/useFeatureStrategy.ts create mode 100644 frontend/src/hooks/api/getters/useStrategies/useStrategies.ts create mode 100644 frontend/src/hooks/api/getters/useUnleashContext/useUnleashContext.ts create mode 100644 frontend/src/hooks/useTabs.ts create mode 100644 frontend/src/interfaces/params.ts diff --git a/frontend/src/assets/icons/gradual.svg b/frontend/src/assets/icons/gradual.svg new file mode 100644 index 0000000000..78f94fc766 --- /dev/null +++ b/frontend/src/assets/icons/gradual.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx index 8880e885a5..fcd183c050 100644 --- a/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx +++ b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx @@ -31,6 +31,9 @@ const AnimateOnMount: FC = ({ setStyles(enter); }, 50); } else { + if (!leave) { + setShow(false); + } setStyles(leave); } } diff --git a/frontend/src/component/common/Dialogue/Dialogue.styles.js b/frontend/src/component/common/Dialogue/Dialogue.styles.ts similarity index 75% rename from frontend/src/component/common/Dialogue/Dialogue.styles.js rename to frontend/src/component/common/Dialogue/Dialogue.styles.ts index 42917ea8f2..4a6376afd4 100644 --- a/frontend/src/component/common/Dialogue/Dialogue.styles.js +++ b/frontend/src/component/common/Dialogue/Dialogue.styles.ts @@ -1,9 +1,9 @@ -import { makeStyles } from '@material-ui/styles'; +import { makeStyles } from '@material-ui/core/styles'; export const useStyles = makeStyles(theme => ({ dialogTitle: { backgroundColor: theme.palette.primary.main, - color: theme.palette.dialogue.title.main, + color: '#fff', height: '150px', padding: '2rem 3rem', clipPath: ' ellipse(130% 115px at 120% 20%)', diff --git a/frontend/src/component/common/Dialogue/Dialogue.jsx b/frontend/src/component/common/Dialogue/Dialogue.tsx similarity index 79% rename from frontend/src/component/common/Dialogue/Dialogue.jsx rename to frontend/src/component/common/Dialogue/Dialogue.tsx index 0d88bdb821..5861476069 100644 --- a/frontend/src/component/common/Dialogue/Dialogue.jsx +++ b/frontend/src/component/common/Dialogue/Dialogue.tsx @@ -6,11 +6,24 @@ import { DialogContent, Button, } from '@material-ui/core'; -import PropTypes from 'prop-types'; + import ConditionallyRender from '../ConditionallyRender/ConditionallyRender'; import { useStyles } from './Dialogue.styles'; -const Dialogue = ({ +interface IDialogue { + primaryButtonText?: string; + secondaryButtonText?: string; + open: boolean; + onClick: () => void; + onClose: () => void; + style?: object; + title: string; + fullWidth?: boolean; + maxWidth?: 'lg' | 'sm' | 'xs' | 'md' | 'xl'; + disabledPrimaryButton?: boolean; +} + +const Dialogue: React.FC = ({ children, open, onClick, @@ -34,7 +47,7 @@ const Dialogue = ({ > {title} {children} @@ -44,7 +57,7 @@ const Dialogue = ({ {secondaryButtonText || 'No take me back'}{' '} @@ -71,16 +84,4 @@ const Dialogue = ({ ); }; -Dialogue.propTypes = { - primaryButtonText: PropTypes.string, - secondaryButtonText: PropTypes.string, - open: PropTypes.bool, - onClick: PropTypes.func, - onClose: PropTypes.func, - ariaLabel: PropTypes.string, - ariaDescription: PropTypes.string, - title: PropTypes.string, - fullWidth: PropTypes.bool, -}; - export default Dialogue; diff --git a/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx b/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx new file mode 100644 index 0000000000..de9d8930e4 --- /dev/null +++ b/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx @@ -0,0 +1,39 @@ +import { useTheme } from '@material-ui/core'; + +interface IPercentageCircleProps { + styles?: object; + percentage: number; +} + +const PercentageCircle = ({ styles, percentage }: IPercentageCircleProps) => { + const theme = useTheme(); + + let circle = { + height: '65px', + width: '65px', + borderRadius: '50%', + color: '#fff', + backgroundColor: theme.palette.grey[200], + backgroundImage: `conic-gradient(${theme.palette.primary.main} ${percentage}%, ${theme.palette.grey[200]} 1%)`, + }; + + if (percentage === 100) { + return ( +
+ 100% +
+ ); + } + + return
; +}; + +export default PercentageCircle; diff --git a/frontend/src/component/common/TabNav/TabNav.jsx b/frontend/src/component/common/TabNav/TabNav.jsx index a068a575a3..47687c5618 100644 --- a/frontend/src/component/common/TabNav/TabNav.jsx +++ b/frontend/src/component/common/TabNav/TabNav.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import classnames from 'classnames'; import PropTypes from 'prop-types'; import { Tabs, Tab, Paper } from '@material-ui/core'; @@ -12,7 +13,7 @@ const a11yProps = index => ({ 'aria-controls': `tabpanel-${index}`, }); -const TabNav = ({ tabData, className, startingTab = 0 }) => { +const TabNav = ({ tabData, className, navClass, startingTab = 0 }) => { const styles = useStyles(); const [activeTab, setActiveTab] = useState(startingTab); const history = useHistory(); @@ -29,14 +30,18 @@ const TabNav = ({ tabData, className, startingTab = 0 }) => { const renderTabPanels = () => tabData.map((tab, index) => ( - + {tab.component} )); return ( <> - + { diff --git a/frontend/src/component/common/select.jsx b/frontend/src/component/common/select.tsx similarity index 60% rename from frontend/src/component/common/select.jsx rename to frontend/src/component/common/select.tsx index 69b2cf8a68..4b415580cd 100644 --- a/frontend/src/component/common/select.jsx +++ b/frontend/src/component/common/select.tsx @@ -1,11 +1,31 @@ import React from 'react'; -import { Select, FormControl, MenuItem, InputLabel } from '@material-ui/core'; -import PropTypes from 'prop-types'; +import { FormControl, InputLabel, MenuItem, Select } from '@material-ui/core'; -const SelectMenu = ({ +export interface ISelectOption { + key: string; + title?: string; + label?: string +} +export interface ISelectMenuProps { + name: string; + id: string; + value?: string; + label?: string; + options: ISelectOption[]; + style?: object; + onChange?: ( + event: React.ChangeEvent<{ name?: string; value: unknown }>, + child: React.ReactNode + ) => void + disabled?: boolean + className?: string; + classes?: any; +} + +const SelectMenu: React.FC = ({ name, - value, - label, + value = '', + label = '', options, onChange, id, @@ -16,7 +36,7 @@ const SelectMenu = ({ }) => { const renderSelectItems = () => options.map(option => ( - + {option.label} )); @@ -33,7 +53,6 @@ const SelectMenu = ({ className={className} label={label} id={id} - size="small" value={value} {...rest} > @@ -43,15 +62,6 @@ const SelectMenu = ({ ); }; -SelectMenu.propTypes = { - name: PropTypes.string, - id: PropTypes.string, - value: PropTypes.string, - label: PropTypes.string, - options: PropTypes.array, - style: PropTypes.object, - onChange: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; + export default SelectMenu; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.styles.ts new file mode 100644 index 0000000000..84bb57b16f --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.styles.ts @@ -0,0 +1,5 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { borderRadius: '10px', boxShadow: 'none', display: 'flex' }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.tsx new file mode 100644 index 0000000000..d932cad823 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategies.tsx @@ -0,0 +1,19 @@ +import { Paper } from '@material-ui/core'; +import FeatureStrategiesList from './FeatureStrategiesList/FeatureStrategiesList'; +import { useStyles } from './FeatureStrategies.styles'; +import FeatureStrategiesUIProvider from './FeatureStrategiesUIProvider'; +import FeatureStrategiesEnvironments from './FeatureStrategiesEnvironments/FeatureStrategiesEnvironments'; + +const FeatureStrategies = () => { + const styles = useStyles(); + return ( + + + + + + + ); +}; + +export default FeatureStrategies; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.styles.ts new file mode 100644 index 0000000000..1cb9d524ef --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.styles.ts @@ -0,0 +1,20 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: '5px', + width: '270px', + marginLeft: 'auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + header: { + color: theme.palette.primary.main, + textAlign: 'center', + margin: '0.5rem 0', + fontSize: theme.fontSizes.bodySize, + marginTop: '1rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.tsx new file mode 100644 index 0000000000..4a91bd6152 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution.tsx @@ -0,0 +1,45 @@ +import { Fragment } from 'react'; +import { IFeatureStrategy } from '../../../../../../interfaces/strategy'; +import { useStyles } from './FeatureEnvironmentStrategyExecution.styles'; +import FeatureEnvironmentStrategyExecutionSeparator from './FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator'; +import FeatureEnvironmentStrategyExecutionWrapper from './FeatureEnvironmentStrategyExecutionWrapper/FeatureEnvironmentStrategyExecutionWrapper'; + +interface IFeatureEnvironmentStrategyExecutionProps { + strategies: IFeatureStrategy[]; +} +const FeatureEnvironmentStrategyExecution = ({ + strategies, + env, +}: IFeatureEnvironmentStrategyExecutionProps) => { + const styles = useStyles(); + + const renderStrategies = () => { + return strategies.map((strategy, index) => { + if (index !== strategies.length - 1) { + return ( + + + + + ); + } + return ( + + ); + }); + }; + + return ( +
+

Strategy execution in {env.name}

+ {renderStrategies()} +
+ ); +}; + +export default FeatureEnvironmentStrategyExecution; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.styles.ts new file mode 100644 index 0000000000..1a3f9b8da8 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.styles.ts @@ -0,0 +1,21 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + position: 'relative', + width: '100%', + height: '25px', + marginTop: '1rem', + }, + separatorBorder: { + height: '1px', + borderBottom: `2px dotted ${theme.palette.primary.main}`, + width: '100%', + top: '0', + }, + textContainer: { + display: 'flex', + justifyContent: 'center', + }, + textPositioning: { position: 'absolute', top: '-20px', zIndex: 300 }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.tsx new file mode 100644 index 0000000000..544af838dc --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionSeparator/FeatureEnvironmentStrategyExecutionSeparator.tsx @@ -0,0 +1,18 @@ +import FeatureStrategiesSeparator from '../../FeatureStrategiesSeparator/FeatureStrategiesSeparator'; +import { useStyles } from './FeatureEnvironmentStrategyExecutionSeparator.styles'; + +const FeatureEnvironmentStrategyExecutionSeparator = () => { + const styles = useStyles(); + return ( +
+
+
+
+ +
+
+
+ ); +}; + +export default FeatureEnvironmentStrategyExecutionSeparator; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionWrapper/FeatureEnvironmentStrategyExecutionWrapper.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionWrapper/FeatureEnvironmentStrategyExecutionWrapper.tsx new file mode 100644 index 0000000000..a824779b0b --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecutionWrapper/FeatureEnvironmentStrategyExecutionWrapper.tsx @@ -0,0 +1,49 @@ +import { useContext } from 'react'; +import { useParams } from 'react-router-dom'; +import FeatureStrategiesUIContext from '../../../../../../../contexts/FeatureStrategiesUIContext'; +import useFeatureStrategy from '../../../../../../../hooks/api/getters/useFeatureStrategy/useFeatureStrategy'; +import { IFeatureViewParams } from '../../../../../../../interfaces/params'; +import FeatureStrategyExecution from '../../../FeatureStrategyExecution/FeatureStrategyExecution'; + +interface IFeatureEnvironmentStrategyExecutionWrapperProps { + strategyId: string; +} + +const FeatureEnvironmentStrategyExecutionWrapper = ({ + strategyId, +}: IFeatureEnvironmentStrategyExecutionWrapperProps) => { + const { projectId, featureId } = useParams(); + const { activeEnvironment } = useContext(FeatureStrategiesUIContext); + + const { strategy } = useFeatureStrategy( + projectId, + featureId, + activeEnvironment.name, + strategyId, + { + revalidateOnMount: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnFocus: false, + } + ); + return ( +
+ +
+ ); +}; + +export default FeatureEnvironmentStrategyExecutionWrapper; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.styles.ts new file mode 100644 index 0000000000..f831187ad0 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.styles.ts @@ -0,0 +1,28 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + padding: '2rem', + }, + buttonContainer: { + marginTop: '1rem', + }, + btn: { + minWidth: '100px', + }, + header: { + fontWeight: 'normal', + marginBottom: '1rem', + fontSize: theme.fontSizes.mainHeader, + }, + configureContainer: { display: 'flex', width: '100%' }, + accordionContainer: { + width: '68%', + }, + executionContainer: { + width: '32%', + }, + envWarning: { + marginBottom: '1rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx new file mode 100644 index 0000000000..4482be0225 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx @@ -0,0 +1,169 @@ +import { Button } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { useContext, useState } from 'react'; +import { getHumanReadbleStrategyName } from '../../../../../../utils/strategy-names'; +import { useParams } from 'react-router-dom'; + +import FeatureStrategiesUIContext from '../../../../../../contexts/FeatureStrategiesUIContext'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; +import FeatureStrategyAccordion from '../../FeatureStrategyAccordion/FeatureStrategyAccordion'; +import useFeatureStrategyApi from '../../../../../../hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; + +import { useStyles } from './FeatureStrategiesConfigure.styles'; +import FeatureStrategiesProductionGuard from '../FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard'; +import { IFeatureViewParams } from '../../../../../../interfaces/params'; +import cloneDeep from 'lodash.clonedeep'; +import FeatureStrategyCreateExecution from '../../FeatureStrategyCreateExecution/FeatureStrategyCreateExecution'; +import { PRODUCTION } from '../../../../../../constants/environmentTypes'; + +interface IFeatureStrategiesConfigure { + setToastData: React.Dispatch>; +} +const FeatureStrategiesConfigure = ({ + setToastData, +}: IFeatureStrategiesConfigure) => { + const { projectId, featureId } = useParams(); + const [productionGuard, setProductionGuard] = useState(false); + + const styles = useStyles(); + const { + activeEnvironment, + setConfigureNewStrategy, + configureNewStrategy, + setExpandedSidebar, + featureCache, + setFeatureCache, + } = useContext(FeatureStrategiesUIContext); + + const [strategyConstraints, setStrategyConstraints] = useState( + configureNewStrategy.constraints + ); + const [strategyParams, setStrategyParams] = useState( + configureNewStrategy.parameters + ); + const { addStrategyToFeature } = useFeatureStrategyApi(); + + const handleCancel = () => { + setConfigureNewStrategy(null); + setExpandedSidebar(true); + }; + + const resolveSubmit = () => { + if (activeEnvironment.type === PRODUCTION) { + setProductionGuard(true); + return; + } + handleSubmit(); + }; + + const handleSubmit = async () => { + const strategyPayload = { + ...configureNewStrategy, + constraints: strategyConstraints, + parameters: strategyParams, + }; + + try { + const res = await addStrategyToFeature( + projectId, + featureId, + activeEnvironment.name, + strategyPayload + ); + const strategy = await res.json(); + + const feature = cloneDeep(featureCache); + const environment = feature.environments.find( + env => env.name === activeEnvironment.name + ); + + environment.strategies.push(strategy); + setFeatureCache(feature); + + setConfigureNewStrategy(null); + setExpandedSidebar(false); + setToastData({ + show: true, + type: 'success', + text: 'Successfully added strategy.', + }); + } catch (e) { + setToastData({ + show: true, + type: 'error', + text: e.toString(), + }); + } + }; + + return ( +
+

+ Configuring{' '} + {getHumanReadbleStrategyName(configureNewStrategy.name)} in{' '} + {activeEnvironment.name} +

+ + This environment is currently enabled. The strategy will + take effect immediately after you save your changes. + + } + elseShow={ + + This environment is currently disabled. The strategy + will not take effect before you enable the environment + on the feature toggle. + + } + /> + +
+
+ +
+
+ +
+
+ +
+ + +
+ { + handleSubmit(); + setProductionGuard(false); + }} + onClose={() => setProductionGuard(false)} + /> +
+ ); +}; + +export default FeatureStrategiesConfigure; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts new file mode 100644 index 0000000000..f7448e01be --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts @@ -0,0 +1,40 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + height: '100%', + width: '100%', + transition: 'background-color 0.4s ease', + }, + isOver: { + backgroundColor: theme.palette.primary.light, + opacity: '0.75', + }, + strategiesContainer: { + maxWidth: '627px', + }, + dropbox: { + textAlign: 'center', + fontSize: theme.fontSizes.smallBody, + padding: '1rem', + border: `2px dotted ${theme.palette.primary.light}`, + borderRadius: '3px', + transition: 'background-color 0.4s ease', + marginTop: '1rem', + }, + dropboxActive: { + border: `2px dotted #fff`, + color: '#fff', + transition: 'color 0.4s ease', + }, + dropIcon: { + fill: theme.palette.primary.light, + marginTop: '0.5rem', + height: '40px', + width: '40px', + }, + dropIconActive: { + fill: '#fff', + transition: 'color 0.4s ease', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx new file mode 100644 index 0000000000..6ca0e80264 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx @@ -0,0 +1,166 @@ +import { + IParameter, + IFeatureStrategy, +} from '../../../../../../interfaces/strategy'; +import { FEATURE_STRATEGIES_DRAG_TYPE } from '../../FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard'; +import { DropTargetMonitor, useDrop } from 'react-dnd'; +import { Fragment } from 'react'; +import { resolveDefaultParamValue } from '../../../../strategy/AddStrategy/utils'; +import useStrategies from '../../../../../../hooks/api/getters/useStrategies/useStrategies'; +import { useStyles } from './FeatureStrategiesEnvironmentList.styles'; +import classnames from 'classnames'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; + +import FeatureStrategiesSeparator from '../FeatureStrategiesSeparator/FeatureStrategiesSeparator'; +import useFeatureStrategiesEnvironmentList from './useFeatureStrategiesEnvironmentList'; +import useDropboxMarkup from './useDropboxMarkup'; +import useDeleteStrategyMarkup from './useDeleteStrategyMarkup'; +import useProductionGuardMarkup from './useProductionGuardMarkup'; +import FeatureStrategyEditable from '../FeatureStrategyEditable/FeatureStrategyEditable'; +import { PRODUCTION } from '../../../../../../constants/environmentTypes'; + +interface IFeatureStrategiesEnvironmentListProps { + strategies: IFeatureStrategy[]; +} + +interface IFeatureDragItem { + name: string; +} + +const FeatureStrategiesEnvironmentList = ({ + strategies, +}: IFeatureStrategiesEnvironmentListProps) => { + const styles = useStyles(); + const { strategies: selectableStrategies } = useStrategies(); + + const { + activeEnvironmentsRef, + toast, + deleteStrategy, + updateStrategy, + delDialog, + setDelDialog, + setProductionGuard, + productionGuard, + setConfigureNewStrategy, + configureNewStrategy, + setExpandedSidebar, + expandedSidebar, + featureId, + } = useFeatureStrategiesEnvironmentList(strategies); + + const [{ isOver }, drop] = useDrop({ + accept: FEATURE_STRATEGIES_DRAG_TYPE, + collect(monitor) { + return { + isOver: monitor.isOver(), + }; + }, + drop(item: IFeatureDragItem, monitor: DropTargetMonitor) { + // const dragIndex = item.index; + // const hoverIndex = index; + + const strategy = selectStrategy(item.name); + if (!strategy) return; + //addNewStrategy(strategy); + setConfigureNewStrategy(strategy); + setExpandedSidebar(false); + }, + }); + + const dropboxMarkup = useDropboxMarkup(isOver, expandedSidebar); + const delDialogueMarkup = useDeleteStrategyMarkup({ + show: delDialog.show, + onClick: () => deleteStrategy(delDialog.strategyId), + onClose: () => setDelDialog({ show: false, strategyId: '' }), + }); + const productionGuardMarkup = useProductionGuardMarkup({ + show: productionGuard.show, + onClick: () => { + updateStrategy(productionGuard.strategy); + productionGuard.callback(); + setProductionGuard({ + show: false, + strategy: null, + }); + }, + onClose: () => + setProductionGuard({ show: false, strategy: null, callback: null }), + }); + + const resolveUpdateStrategy = (strategy: IFeatureStrategy, callback) => { + if (activeEnvironmentsRef?.current?.type === PRODUCTION) { + setProductionGuard({ show: true, strategy, callback }); + return; + } + + updateStrategy(strategy); + }; + + const selectStrategy = (name: string) => { + const selectedStrategy = selectableStrategies.find( + strategy => strategy.name === name + ); + const parameters = {} as IParameter; + + selectedStrategy?.parameters.forEach(({ name }: IParameter) => { + parameters[name] = resolveDefaultParamValue(name, featureId); + }); + + return { name, parameters, constraints: [] }; + }; + + const renderStrategies = () => { + return strategies.map((strategy, index) => { + if (index !== strategies.length - 1) { + return ( + + + + + + ); + } else { + return ( + + ); + } + }); + }; + + const classes = classnames(styles.container, { + [styles.isOver]: isOver, + }); + + const strategiesContainerClasses = classnames({ + [styles.strategiesContainer]: !expandedSidebar, + }); + + return ( + +
+ {renderStrategies()} +
+ {dropboxMarkup} + {toast} + {delDialogueMarkup} + {productionGuardMarkup} +
+ } + /> + ); +}; + +export default FeatureStrategiesEnvironmentList; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDeleteStrategyMarkup.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDeleteStrategyMarkup.tsx new file mode 100644 index 0000000000..fb92efed37 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDeleteStrategyMarkup.tsx @@ -0,0 +1,32 @@ +import { Alert } from '@material-ui/lab'; +import Dialogue from '../../../../../common/Dialogue'; + +interface IUseDeleteStrategyMarkupProps { + show: boolean; + onClick: () => void; + onClose: () => void; +} + +const useDeleteStrategyMarkup = ({ + show, + onClick, + onClose, +}: IUseDeleteStrategyMarkupProps) => { + return ( + + + Deleting the strategy will change which users receive access to + the feature. + + + ); +}; + +export default useDeleteStrategyMarkup; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDropboxMarkup.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDropboxMarkup.tsx new file mode 100644 index 0000000000..8d714b1040 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useDropboxMarkup.tsx @@ -0,0 +1,30 @@ +import { GetApp } from '@material-ui/icons'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; +import classnames from 'classnames'; +import { useStyles } from './FeatureStrategiesEnvironmentList.styles'; + +const useDropboxMarkup = (isOver: boolean, expandedSidebar: boolean) => { + const styles = useStyles(); + + const dropboxClasses = classnames(styles.dropbox, { + [styles.dropboxActive]: isOver, + }); + + const iconClasses = classnames(styles.dropIcon, { + [styles.dropIconActive]: isOver, + }); + + return ( + +

Drag and drop strategies from the left side menu

+ +
+ } + /> + ); +}; + +export default useDropboxMarkup; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts new file mode 100644 index 0000000000..8e2ac11264 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts @@ -0,0 +1,136 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import FeatureStrategiesUIContext from '../../../../../../contexts/FeatureStrategiesUIContext'; +import useFeatureStrategyApi from '../../../../../../hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import useToast from '../../../../../../hooks/useToast'; +import { IFeatureViewParams } from '../../../../../../interfaces/params'; +import { IFeatureStrategy } from '../../../../../../interfaces/strategy'; +import cloneDeep from 'lodash.clonedeep'; + +const useFeatureStrategiesEnvironmentList = (strategies: IFeatureStrategy[]) => { + const { projectId, featureId } = useParams(); + + const { deleteStrategyFromFeature, updateStrategyOnFeature } = + useFeatureStrategyApi(); + + const { + setConfigureNewStrategy, + configureNewStrategy, + activeEnvironment, + setExpandedSidebar, + expandedSidebar, + setFeatureCache, + featureCache, + } = useContext(FeatureStrategiesUIContext); + + const { toast, setToastData } = useToast(); + const [delDialog, setDelDialog] = useState({ strategyId: '', show: false }); + const [productionGuard, setProductionGuard] = useState({ + show: false, + strategyId: '', + }); + + const activeEnvironmentsRef = useRef(null); + + useEffect(() => { + activeEnvironmentsRef.current = activeEnvironment; + }, [activeEnvironment]); + + const updateStrategy = async (updatedStrategy: IFeatureStrategy) => { + try { + const updateStrategyPayload: IStrategyPayload = { + constraints: updatedStrategy.constraints, + parameters: updatedStrategy.parameters, + }; + + await updateStrategyOnFeature( + projectId, + featureId, + activeEnvironment.id, + updatedStrategy.id, + updateStrategyPayload + ); + + setToastData({ + show: true, + type: 'success', + text: `Successfully updated strategy`, + }); + + const feature = cloneDeep(featureCache); + + const environment = feature.environments.find( + env => env.name === activeEnvironment.name + ); + + const strategy = environment.strategies.find( + strategy => strategy.id === updatedStrategy.id + ); + + strategy.parameters = updateStrategyPayload.parameters; + strategy.constraints = updateStrategyPayload.constraints; + + setFeatureCache(feature); + } catch (e) { + setToastData({ + show: true, + type: 'error', + text: e.toString(), + }); + } + }; + + const deleteStrategy = async (strategyId: string) => { + try { + const environmentId = activeEnvironment.name; + await deleteStrategyFromFeature( + projectId, + featureId, + environmentId, + strategyId + ); + + const feature = cloneDeep(featureCache); + const environment = feature.environments.find( + env => env.name === environmentId + ); + const strategyIdx = environment.strategies.findIndex( + strategy => strategy.id === strategyId + ); + + environment.strategies.splice(strategyIdx, 1); + setFeatureCache(feature); + + setDelDialog({ strategyId: '', show: false }); + setToastData({ + show: true, + type: 'success', + text: `Successfully deleted strategy from ${featureId}`, + }); + } catch (e) { + setToastData({ + show: true, + type: 'error', + text: e.toString(), + }); + } + }; + + return { + activeEnvironmentsRef, + toast, + deleteStrategy, + updateStrategy, + delDialog, + setDelDialog, + setProductionGuard, + productionGuard, + setConfigureNewStrategy, + configureNewStrategy, + setExpandedSidebar, + expandedSidebar, + featureId, + }; +}; + +export default useFeatureStrategiesEnvironmentList; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useProductionGuardMarkup.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useProductionGuardMarkup.tsx new file mode 100644 index 0000000000..52666715ed --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useProductionGuardMarkup.tsx @@ -0,0 +1,24 @@ +import FeatureStrategiesProductionGuard from '../FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard'; + +interface IUseProductionGuardMarkupProps { + show: boolean; + onClick: () => void; + onClose: () => void; +} + +const useProductionGuardMarkup = ({ + show, + onClick, + onClose, +}: IUseProductionGuardMarkupProps) => { + return ( + + ); +}; + +export default useProductionGuardMarkup; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts new file mode 100644 index 0000000000..6e0a6e5d7c --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts @@ -0,0 +1,44 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + width: '70%', + }, + fullWidth: { + width: '90%', + }, + environmentsHeader: { + padding: '2rem 2rem 1rem 2rem', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + tabContentContainer: { + padding: '1rem 2rem 2rem 2rem', + display: 'flex', + justifyContent: 'space-between', + }, + listContainer: { width: '70%' }, + listContainerFullWidth: { width: '100%' }, + containerListView: { + display: 'none', + }, + header: { + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + }, + tabContainer: { + margin: '0rem 2rem 2rem 2rem', + }, + tabNavigation: { + backgroundColor: 'transparent', + textTransform: 'none', + boxShadow: 'none', + borderBottom: `1px solid ${theme.palette.grey[400]}`, + width: '100%', + }, + tabButton: { + textTransform: 'none', + width: 'auto', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx new file mode 100644 index 0000000000..89c3ee0692 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx @@ -0,0 +1,301 @@ +import { useParams } from 'react-router-dom'; +import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature'; +import { useStyles } from './FeatureStrategiesEnvironments.styles'; +import { Tabs, Tab, Button } from '@material-ui/core'; +import TabPanel from '../../../../common/TabNav/TabPanel'; +import useTabs from '../../../../../hooks/useTabs'; +import FeatureStrategiesEnvironmentList from './FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList'; +import { useContext, useEffect, useState } from 'react'; +import FeatureStrategiesUIContext from '../../../../../contexts/FeatureStrategiesUIContext'; +import ConditionallyRender from '../../../../common/ConditionallyRender'; +import FeatureStrategiesConfigure from './FeatureStrategiesConfigure/FeatureStrategiesConfigure'; +import classNames from 'classnames'; +import useToast from '../../../../../hooks/useToast'; +import { IFeatureViewParams } from '../../../../../interfaces/params'; +import cloneDeep from 'lodash.clonedeep'; +import FeatureStrategiesRefresh from './FeatureStrategiesRefresh/FeatureStrategiesRefresh'; +import FeatureEnvironmentStrategyExecution from './FeatureEnvironmentStrategyExecution/FeatureEnvironmentStrategyExecution'; + +const FeatureStrategiesEnvironments = () => { + const startingTabId = 0; + const { projectId, featureId } = useParams(); + const { toast, setToastData } = useToast(); + const [showRefreshPrompt, setShowRefreshPrompt] = useState(false); + + const styles = useStyles(); + const { a11yProps, activeTab, setActiveTab } = useTabs(startingTabId); + const { + setActiveEnvironment, + configureNewStrategy, + expandedSidebar, + setExpandedSidebar, + featureCache, + setFeatureCache, + } = useContext(FeatureStrategiesUIContext); + + const { feature } = useFeature(projectId, featureId, { + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + refreshInterval: 5000, + }); + + useEffect(() => { + if (!feature) return; + if (featureCache === null || !featureCache.createdAt) { + setFeatureCache(cloneDeep(feature)); + } + /* eslint-disable-next-line */ + }, [feature]); + + useEffect(() => { + if (!feature) return; + if (featureCache === null) return; + if (!featureCache.createdAt) return; + + const equal = compareCacheToFeature(); + + if (!equal) { + setShowRefreshPrompt(true); + } + /*eslint-disable-next-line */ + }, [feature]); + + useEffect(() => { + setActiveEnvironment(feature?.environments[activeTab]); + /* eslint-disable-next-line */ + }, [feature]); + + const renderTabs = () => { + return featureCache?.environments?.map((env, index) => { + return ( + setActiveTab(index)} + className={styles.tabButton} + /> + ); + }); + }; + + const compareCacheToFeature = () => { + let equal = true; + // If the length of environments are different + if (!featureCache) return false; + if ( + feature?.environments?.length !== featureCache?.environments?.length + ) { + equal = false; + } + + feature.environments.forEach(env => { + const cachedEnv = featureCache.environments.find( + cacheEnv => cacheEnv.name === env.name + ); + + if (!cachedEnv) { + equal = false; + return; + } + // If displayName is different + if (env.displayName !== cachedEnv.displayName) { + equal = false; + return; + } + // If the type of environments are different + if (env.type !== cachedEnv.type) { + equal = false; + return; + } + }); + + if (!equal) return equal; + + feature.environments.forEach(env => { + const cachedEnv = featureCache.environments.find( + cachedEnv => cachedEnv.name === env.name + ); + + if (!cachedEnv) return; + + if (cachedEnv.strategies.length !== env.strategies.length) { + equal = false; + return; + } + + env.strategies.forEach(strategy => { + const cachedStrategy = cachedEnv.strategies.find( + cachedStrategy => cachedStrategy.id === strategy.id + ); + // Check stickiness + if (cachedStrategy?.stickiness !== strategy?.stickiness) { + equal = false; + return; + } + + if (cachedStrategy?.groupId !== strategy?.groupId) { + equal = false; + return; + } + + // Check groupId + + const cacheParamKeys = Object.keys(cachedStrategy?.parameters); + const strategyParamKeys = Object.keys(strategy?.parameters); + // Check length of parameters + if (cacheParamKeys.length !== strategyParamKeys.length) { + equal = false; + return; + } + + // Make sure parameters are the same + strategyParamKeys.forEach(key => { + const found = cacheParamKeys.find( + cacheKey => cacheKey === key + ); + + if (!found) { + equal = false; + return; + } + }); + + // Check value of parameters + strategyParamKeys.forEach(key => { + const strategyValue = strategy.parameters[key]; + const cachedValue = cachedStrategy.parameters[key]; + + if (strategyValue !== cachedValue) { + equal = false; + return; + } + }); + + // Check length of constraints + const cachedConstraints = cachedStrategy.constraints; + const strategyConstraints = strategy.constraints; + + if (cachedConstraints.length !== strategyConstraints.length) { + equal = false; + return; + } + + // Check constraints -> are we g uaranteed that constraints will occur in the same order each time? + }); + }); + + return equal; + + // If the parameter values are different + // If the constraint length is different + // If the constraint operators are different + // If the constraint values are different + // If the stickiness is different + // If the groupId is different + }; + + const renderTabPanels = () => { + const tabContentClasses = classNames(styles.tabContentContainer, { + [styles.containerListView]: configureNewStrategy, + }); + + const listContainerClasses = classNames(styles.listContainer, { + [styles.listContainerFullWidth]: expandedSidebar, + }); + + return featureCache?.environments?.map((env, index) => { + return ( + +
+
+ +
+ + } + /> +
+
+ ); + }); + }; + + const handleRefresh = () => { + setFeatureCache(cloneDeep(feature)); + setShowRefreshPrompt(false); + }; + + const handleCancel = () => { + setShowRefreshPrompt(false); + }; + + const classes = classNames(styles.container, { + [styles.fullWidth]: !expandedSidebar, + }); + + return ( +
+
+

Environments

+ + + +
+
+ { + setActiveTab(tabId); + setActiveEnvironment(featureCache?.environments[tabId]); + }} + indicatorColor="primary" + textColor="primary" + className={styles.tabNavigation} + > + {renderTabs()} + +
+ +
+ {renderTabPanels()} + + } + /> +
+ {toast} +
+ ); +}; + +export default FeatureStrategiesEnvironments; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard.tsx new file mode 100644 index 0000000000..ca29679a46 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard.tsx @@ -0,0 +1,38 @@ +import { Alert } from '@material-ui/lab'; +import Dialogue from '../../../../../common/Dialogue'; + +interface IFeatureStrategiesProductionGuard { + show: boolean; + onClick: () => void; + onClose: () => void; + primaryButtonText: string; +} + +const FeatureStrategiesProductionGuard = ({ + show, + onClick, + onClose, + primaryButtonText, +}: IFeatureStrategiesProductionGuard) => { + return ( + + + WARNING. You are about to make changes to a production + environment. These changes will affect your customers. + + +

+ Are you sure you want to proceed? +

+
+ ); +}; + +export default FeatureStrategiesProductionGuard; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.styles.ts new file mode 100644 index 0000000000..76189f0db9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.styles.ts @@ -0,0 +1,41 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + borderRadius: '12.5px', + padding: '2rem', + backgroundColor: '#fff', + maxWidth: '450px', + boxShadow: `2px 2px 4px rgba(0,0,0,.4)`, + }, + refreshHeader: { + fontSize: theme.fontSizes.mainHeader, + marginBottom: '0.5rem', + }, + paragraph: { marginBottom: '0.5rem' }, + buttonContainer: { + display: 'flex', + marginTop: '1rem', + }, + mainBtn: { + marginRight: '1rem', + }, + fadeInStart: { + opacity: '0', + position: 'fixed', + right: '40px', + top: '100px', + transform: 'translateX(-400px)', + zIndex: 400, + }, + fadeInEnter: { + transform: 'translateX(0)', + opacity: '1', + transition: 'transform 0.6s ease, opacity 1s ease', + }, + fadeInLeave: { + transform: 'translateX(400px)', + opacity: '0', + transition: 'transform 1.25s ease, opacity 1s ease', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.tsx new file mode 100644 index 0000000000..92bed2c94c --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesRefresh/FeatureStrategiesRefresh.tsx @@ -0,0 +1,52 @@ +import { Button } from '@material-ui/core'; +import AnimateOnMount from '../../../../../common/AnimateOnMount/AnimateOnMount'; +import { useStyles } from './FeatureStrategiesRefresh.styles'; + +interface IFeatureStrategiesRefreshProps { + show: boolean; + refresh: () => void; + cancel: () => void; +} + +const FeatureStrategiesRefresh = ({ + show, + refresh, + cancel, +}: IFeatureStrategiesRefreshProps) => { + const styles = useStyles(); + + return ( + +
+

+ NOTE: Updated configuration +

+

+ There is new strategy configuration available. This might + mean that someone has updated the strategy configuration + while you were working. Would you like to refresh? +

+ +
+ + + +
+
+
+ ); +}; + +export default FeatureStrategiesRefresh; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesSeparator/FeatureStrategiesSeparator.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesSeparator/FeatureStrategiesSeparator.tsx new file mode 100644 index 0000000000..abc4cea63e --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesSeparator/FeatureStrategiesSeparator.tsx @@ -0,0 +1,32 @@ +import { useTheme } from '@material-ui/core'; + +interface IFeatureStrategiesSeparatorProps { + text: string; + maxWidth?: string; +} + +const FeatureStrategiesSeparator = ({ + text, + maxWidth = '50px', +}: IFeatureStrategiesSeparatorProps) => { + const theme = useTheme(); + return ( +
+ {text} +
+ ); +}; + +export default FeatureStrategiesSeparator; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.styles.ts new file mode 100644 index 0000000000..1936a6ce5d --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.styles.ts @@ -0,0 +1,23 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + editableContainer: { + position: 'relative', + }, + unsaved: { + position: 'absolute', + top: '-12.5px', + right: '175px', + backgroundColor: theme.palette.primary.main, + color: '#fff', + padding: '0.15rem 0.2rem', + borderRadius: '3px', + fontSize: theme.fontSizes.smallerBody, + zIndex: 400, + }, + buttonContainer: { + display: 'flex', + alignItems: 'center', + marginTop: '1rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx new file mode 100644 index 0000000000..3e786587b7 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategyEditable/FeatureStrategyEditable.tsx @@ -0,0 +1,182 @@ +import { useContext, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { mutate } from 'swr'; +import FeatureStrategiesUIContext from '../../../../../../contexts/FeatureStrategiesUIContext'; +import useFeatureStrategy from '../../../../../../hooks/api/getters/useFeatureStrategy/useFeatureStrategy'; +import { IFeatureViewParams } from '../../../../../../interfaces/params'; +import { + IConstraint, + IParameter, + IFeatureStrategy, +} from '../../../../../../interfaces/strategy'; +import FeatureStrategyAccordion from '../../FeatureStrategyAccordion/FeatureStrategyAccordion'; +import cloneDeep from 'lodash.clonedeep'; +import { Button, IconButton, Tooltip } from '@material-ui/core'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; +import { useStyles } from './FeatureStrategyEditable.styles'; +import { Delete, FileCopy } from '@material-ui/icons'; +import { PRODUCTION } from '../../../../../../constants/environmentTypes'; + +interface IFeatureStrategyEditable { + currentStrategy: IFeatureStrategy; + setDelDialog?: React.Dispatch>; + updateStrategy: (strategy: IFeatureStrategy) => void; +} + +const FeatureStrategyEditable = ({ + currentStrategy, + updateStrategy, + setDelDialog, +}: IFeatureStrategyEditable) => { + const { projectId, featureId } = useParams(); + const { activeEnvironment, featureCache, dirty, setDirty } = useContext( + FeatureStrategiesUIContext + ); + const [strategyCache, setStrategyCache] = useState( + null + ); + const styles = useStyles(); + + const { strategy, FEATURE_STRATEGY_CACHE_KEY } = useFeatureStrategy( + projectId, + featureId, + activeEnvironment.name, + currentStrategy.id, + { + revalidateOnMount: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnFocus: false, + } + ); + + const setStrategyParams = (parameters: IParameter) => { + const updatedStrategy = { ...strategy }; + updatedStrategy.parameters = parameters; + mutate(FEATURE_STRATEGY_CACHE_KEY, { ...updatedStrategy }, false); + + const dirtyParams = isDirtyParams(parameters); + setDirty(prev => ({ ...prev, [strategy.id]: dirtyParams })); + }; + + const updateFeatureStrategy = () => { + const cleanup = () => { + setStrategyCache(cloneDeep(strategy)); + setDirty(prev => ({ ...prev, [strategy.id]: false })); + }; + + updateStrategy(strategy, cleanup); + + if (activeEnvironment.type !== PRODUCTION) { + cleanup(); + } + }; + + useEffect(() => { + const dirtyStrategy = dirty[strategy.id]; + if (dirtyStrategy) return; + + mutate(FEATURE_STRATEGY_CACHE_KEY, { ...currentStrategy }, false); + setStrategyCache(cloneDeep(currentStrategy)); + /* eslint-disable-next-line */ + }, [featureCache]); + + const isDirtyParams = (parameters: IParameter) => { + const initialParams = strategyCache?.parameters; + + if (!initialParams || !parameters) return false; + + const keys = Object.keys(initialParams); + + const dirty = keys.some(key => { + const old = initialParams[key]; + const current = parameters[key]; + + return old !== current; + }); + + return dirty; + }; + + const discardChanges = () => { + mutate(FEATURE_STRATEGY_CACHE_KEY, { ...strategyCache }, false); + setDirty(prev => ({ ...prev, [strategy.id]: false })); + }; + + const setStrategyConstraints = (constraints: IConstraint[]) => { + const updatedStrategy = { ...strategy }; + updatedStrategy.constraints = constraints; + mutate(FEATURE_STRATEGY_CACHE_KEY, { ...updatedStrategy }, false); + setDirty(prev => ({ ...prev, [strategy.id]: true })); + }; + + if (!strategy.id) return null; + const { parameters, constraints } = strategy; + + return ( +
+ Unsaved changes
} + /> + + + { + e.stopPropagation(); + setDelDialog({ + strategyId: strategy.id, + show: true, + }); + }} + > + + + + + + { + e.stopPropagation(); + }} + > + + + + + } + > + +
+ + +
+ + } + /> +
+ + ); +}; + +export default FeatureStrategyEditable; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.styles.ts new file mode 100644 index 0000000000..0e2fd1c6de --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.styles.ts @@ -0,0 +1,13 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + sidebar: { + width: '30%', + padding: '2rem', + borderRight: `1px solid ${theme.palette.grey[300]}`, + transition: 'width 0.3s ease', + }, + sidebarSmall: { + width: '10%', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.tsx new file mode 100644 index 0000000000..27413d710f --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategiesList.tsx @@ -0,0 +1,35 @@ +import useStrategies from '../../../../../hooks/api/getters/useStrategies/useStrategies'; +import { IStrategy } from '../../../../../interfaces/strategy'; +import FeatureStrategyCard from './FeatureStrategyCard/FeatureStrategyCard'; +import { useStyles } from './FeatureStrategiesList.styles'; +import { useContext } from 'react'; +import FeatureStrategiesUIContext from '../../../../../contexts/FeatureStrategiesUIContext'; +import classnames from 'classnames'; + +const FeatureStrategiesList = () => { + const { expandedSidebar } = useContext(FeatureStrategiesUIContext); + const styles = useStyles(); + + const { strategies } = useStrategies(); + + const renderStrategies = () => { + return strategies + .filter((strategy: IStrategy) => !strategy.deprecated) + .map((strategy: IStrategy) => ( + + )); + }; + + const classes = classnames(styles.sidebar, { + [styles.sidebarSmall]: !expandedSidebar, + }); + + return
{renderStrategies()}
; +}; + +export default FeatureStrategiesList; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.styles.ts new file mode 100644 index 0000000000..a9263f6f9f --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.styles.ts @@ -0,0 +1,47 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + featureStrategyCard: { + padding: '1rem', + maxWidth: '290px', + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: '3px', + margin: '0.5rem 0', + display: 'flex', + '&:active': { + backgroundColor: theme.palette.primary.main, + color: '#fff', + }, + }, + title: { + maxWidth: '150px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, + leftSection: { + display: 'flex', + height: '100%', + marginRight: '1rem', + }, + iconContainer: { + width: '50px', + height: '50px', + borderRadius: '50%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + boxShadow: '1px 1px 2px rgb(135 135 135 / 40%)', + backgroundColor: '#fff', + }, + icon: { + fill: theme.palette.primary.main, + }, + description: { + marginTop: '0.5rem', + fontSize: theme.fontSizes.smallerBody, + }, + isDragging: { + backgroundColor: theme.palette.primary.main, + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx new file mode 100644 index 0000000000..79d7d1755a --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesList/FeatureStrategyCard/FeatureStrategyCard.tsx @@ -0,0 +1,68 @@ +import { IconButton, Tooltip } from '@material-ui/core'; +import classNames from 'classnames'; +import { useDrag } from 'react-dnd'; +import { getFeatureStrategyIcon, getHumanReadbleStrategyName } from '../../../../../../utils/strategy-names'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; +import { useStyles } from './FeatureStrategyCard.styles'; + +interface IFeatureStrategyCardProps { + name: string; + description: string; + configureNewStrategy: boolean; +} + +export const FEATURE_STRATEGIES_DRAG_TYPE = 'FEATURE_STRATEGIES_DRAG_TYPE'; + +const FeatureStrategyCard = ({ + name, + description, + configureNewStrategy, +}: IFeatureStrategyCardProps) => { + const styles = useStyles(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [ _ , drag] = useDrag({ + type: FEATURE_STRATEGIES_DRAG_TYPE, + item: () => { + return { name }; + }, + collect: (monitor: any) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const readableName = getHumanReadbleStrategyName(name); + const Icon = getFeatureStrategyIcon(name); + + const classes = classNames(styles.featureStrategyCard); + + return ( + <> + +
+
+ {} +
+
+
+ +

{readableName}

+
+

{description}

+
+ + } + elseShow={ + + + + } + /> + + ); +}; + +export default FeatureStrategyCard; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesUIProvider.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesUIProvider.tsx new file mode 100644 index 0000000000..87262c4521 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesUIProvider.tsx @@ -0,0 +1,40 @@ +import React, { FC, useState } from 'react'; +import FeatureStrategiesUIContext from '../../../../contexts/FeatureStrategiesUIContext'; +import { + IFeatureEnvironment, + IFeatureToggle, +} from '../../../../interfaces/featureToggle'; +import { IStrategyPayload } from '../../../../interfaces/strategy'; + +const FeatureStrategiesUIProvider: FC = ({ children }) => { + const [configureNewStrategy, setConfigureNewStrategy] = + useState(null); + const [activeEnvironment, setActiveEnvironment] = + useState(null); + const [expandedSidebar, setExpandedSidebar] = useState(false); + const [featureCache, setFeatureCache] = useState( + null + ); + const [dirty, setDirty] = useState({}); + const context = { + configureNewStrategy, + setConfigureNewStrategy, + setActiveEnvironment, + activeEnvironment, + expandedSidebar, + setExpandedSidebar, + featureCache, + setFeatureCache, + + setDirty, + dirty, + }; + + return ( + + {children} + + ); +}; + +export default FeatureStrategiesUIProvider; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.styles.ts new file mode 100644 index 0000000000..856a913a00 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.styles.ts @@ -0,0 +1,46 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: '5px', + transition: 'transform 0.3s ease', + transitionDelay: '0.1s', + position: 'relative', + }, + unsaved: { + position: 'absolute', + top: '-12.5px', + right: '175px', + backgroundColor: theme.palette.primary.main, + color: '#fff', + padding: '0.15rem 0.2rem', + borderRadius: '3px', + fontSize: theme.fontSizes.smallerBody, + }, + accordion: { + boxShadow: 'none', + }, + accordionSummary: { + display: 'flex', + alignItems: 'center', + width: '100%', + }, + accordionHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + accordionActions: { + marginLeft: 'auto', + }, + icon: { + marginRight: '0.5rem', + fill: theme.palette.primary.main, + minWidth: '35px', + }, + rollout: { + fontSize: theme.fontSizes.smallBody, + marginLeft: '0.5rem', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.tsx new file mode 100644 index 0000000000..2e1a7aa707 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordion.tsx @@ -0,0 +1,86 @@ +import { IConstraint, IParameter, IFeatureStrategy } from '../../../../../interfaces/strategy'; + +import Accordion from '@material-ui/core/Accordion'; +import { AccordionDetails, AccordionSummary } from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { getFeatureStrategyIcon, getHumanReadbleStrategyName } from '../../../../../utils/strategy-names'; +import { useStyles } from './FeatureStrategyAccordion.styles'; +import ConditionallyRender from '../../../../common/ConditionallyRender'; +import FeatureStrategyAccordionBody from './FeatureStrategyAccordionBody/FeatureStrategyAccordionBody'; +import React from 'react'; + +interface IFeatureStrategyAccordionProps { + strategy: IFeatureStrategy; + expanded?: boolean; + parameters: IParameter; + constraints: IConstraint[]; + setStrategyParams: (paremeters: IParameter, strategyId?: string) => any; + setStrategyConstraints: React.Dispatch>; + updateStrategy?: (strategyId: string) => void; + actions?: JSX.Element | JSX.Element[]; +} + +const FeatureStrategyAccordion: React.FC = ({ + strategy, + expanded = false, + setStrategyParams, + parameters, + constraints, + setStrategyConstraints, + actions, + children, +}) => { + const styles = useStyles(); + const strategyName = getHumanReadbleStrategyName(strategy.name); + const Icon = getFeatureStrategyIcon(strategy.name); + + const updateParameters = (field: string, value: any) => { + setStrategyParams({ ...parameters, [field]: value }); + }; + + const updateConstraints = (constraints: IConstraint[]) => { + setStrategyConstraints(constraints); + }; + + return ( +
+ + } + aria-controls="strategy-content" + id={strategy.name} + > +
+

+ {strategyName} +

+ + + Rolling out to {parameters?.rollout}% +

+ } + /> + +
{actions}
+
+
+ + + {children} + + +
+
+ ); +}; + +export default FeatureStrategyAccordion; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.styles.ts new file mode 100644 index 0000000000..c36c685f18 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.styles.ts @@ -0,0 +1,31 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + addConstraintBtn: { + color: theme.palette.primary.main, + fontWeight: 'normal', + marginBottom: '0.5rem', + }, + constraintHeader: { + fontWeight: 'bold', + fontSize: theme.fontSizes.smallBody, + }, + noConstraints: { + marginTop: '0.5rem', + fontSize: theme.fontSizes.smallBody, + }, + constraint: { + display: 'flex', + alignItems: 'center', + padding: '0.1rem 0.5rem', + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: '5px', + fontSize: theme.fontSizes.smallBody, + width: 'auto', + margin: '0.5rem 0', + }, + values: { marginLeft: '1.5rem' }, + contextName: { + minWidth: '100px', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx new file mode 100644 index 0000000000..0a9dba7a23 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyAccordion/FeatureStrategyAccordionBody/FeatureStrategyAccordionBody.tsx @@ -0,0 +1,182 @@ +import DefaultStrategy from '../../../../strategy/EditStrategyModal/default-strategy'; +import FlexibleStrategy from '../../common/FlexibleStrategy/FlexibleStrategy'; +import { IConstraint, IFeatureStrategy } from '../../../../../../interfaces/strategy'; +import useUnleashContext from '../../../../../../hooks/api/getters/useUnleashContext/useUnleashContext'; +import useStrategies from '../../../../../../hooks/api/getters/useStrategies/useStrategies'; +import GeneralStrategy from '../../common/GeneralStrategy/GeneralStrategy'; +import UserWithIdStrategy from '../../common/UserWithIdStrategy/UserWithId'; +import StrategyConstraints from '../../common/StrategyConstraints/StrategyConstraints'; +import { useState } from 'react'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; +import useUiConfig from '../../../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import { C } from '../../../../../common/flags'; +import { Button } from '@material-ui/core'; +import { useStyles } from './FeatureStrategyAccordionBody.styles'; +import Dialogue from '../../../../../common/Dialogue'; +import FeatureStrategiesSeparator from '../../FeatureStrategiesEnvironments/FeatureStrategiesSeparator/FeatureStrategiesSeparator'; + +interface IFeatureStrategyAccordionBodyProps { + strategy: IFeatureStrategy; + setStrategyParams: () => any; + updateParameters: (field: string, value: any) => any; + updateConstraints: (constraints: IConstraint[]) => void; + constraints: IConstraint[]; + setStrategyConstraints: React.Dispatch>; +} + +const FeatureStrategyAccordionBody: React.FC = + ({ + strategy, + updateParameters, + children, + constraints, + updateConstraints, + setStrategyConstraints, + }) => { + const styles = useStyles(); + const [constraintError, setConstraintError] = useState({}); + const { strategies } = useStrategies(); + const { uiConfig } = useUiConfig(); + const [showConstraints, setShowConstraints] = useState(false); + + const { context } = useUnleashContext(); + + const resolveInputType = () => { + switch (strategy?.name) { + case 'default': + return DefaultStrategy; + case 'flexibleRollout': + return FlexibleStrategy; + case 'userWithId': + return UserWithIdStrategy; + default: + return GeneralStrategy; + } + }; + + const toggleConstraints = () => setShowConstraints(prev => !prev); + + const resolveStrategyDefinition = () => { + const definition = strategies.find( + definition => definition.name === strategy.name + ); + + return definition; + }; + + const saveConstraintsLocally = () => { + let valid = true; + + constraints.forEach((constraint, index) => { + const { values } = constraint; + + if (values.length === 0) { + setConstraintError(prev => ({ + ...prev, + [`${constraint.contextName}-${index}`]: + 'You need to specify at least one value', + })); + valid = false; + } + }); + + if (valid) { + setShowConstraints(false); + setStrategyConstraints(constraints); + } + }; + + const renderConstraints = () => { + if (constraints.length === 0) { + return ( +

+ No constraints configured +

+ ); + } + + return constraints.map((constraint, index) => { + return ( +
+ + {constraint.contextName} + + + + {constraint.values.join(', ')} + +
+ ); + }); + }; + + const closeConstraintDialog = () => { + setShowConstraints(false); + const filteredConstraints = constraints.filter(constraint => { + return constraint.values.length > 0; + }); + updateConstraints(filteredConstraints); + }; + + const Type = resolveInputType(); + const definition = resolveStrategyDefinition(); + + const { parameters } = strategy; + const ON = uiConfig.flags[C]; + + return ( +
+ +

+ Constraints +

+ {renderConstraints()} + + + } + /> + + + + + + {children} +
+ ); + }; + +export default FeatureStrategyAccordionBody; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.styles.ts new file mode 100644 index 0000000000..8176219b4a --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.styles.ts @@ -0,0 +1,20 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: '5px', + maxWidth: '270px', + padding: '1rem', + marginLeft: 'auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + header: { + color: theme.palette.primary.main, + textAlign: 'center', + margin: '0.5rem 0', + fontSize: theme.fontSizes.bodySize, + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.tsx new file mode 100644 index 0000000000..42de8ec688 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyCreateExecution/FeatureStrategyCreateExecution.tsx @@ -0,0 +1,26 @@ +import { IConstraint, IParameter } from '../../../../../interfaces/strategy'; +import FeatureStrategyExecution from '../FeatureStrategyExecution/FeatureStrategyExecution'; +import { useStyles } from './FeatureStrategyCreateExecution.styles'; + +interface IFeatureStrategyCreateExecutionProps { + parameters: IParameter; + constraints: IConstraint[]; +} + +const FeatureStrategyCreateExecution = ({ + parameters, + constraints, +}: IFeatureStrategyCreateExecutionProps) => { + const styles = useStyles(); + return ( +
+

Execution plan

+ +
+ ); +}; + +export default FeatureStrategyCreateExecution; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.styles.ts new file mode 100644 index 0000000000..250274505c --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.styles.ts @@ -0,0 +1,34 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + constraintsContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginTop: '0.5rem', + width: '100%', + }, + + constraint: { + fontSize: theme.fontSizes.smallBody, + alignItems: 'center;', + margin: '0.5rem 0', + display: 'flex', + border: `1px solid ${theme.palette.grey[300]}`, + padding: '0.2rem', + borderRadius: '5px', + }, + constraintName: { + minWidth: '100px', + marginRight: '0.5rem', + }, + constraintOperator: { + marginRight: '0.5rem', + }, + constraintValues: { + textOverflow: 'ellipsis', + overflow: 'hidden', + maxWidth: '50%', + }, + text: { textAlign: 'center', margin: '0.2rem 0 0.5rem' }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.tsx new file mode 100644 index 0000000000..7708fb17bd --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.tsx @@ -0,0 +1,120 @@ +import { Fragment } from 'react'; +import { IConstraint, IParameter } from '../../../../../interfaces/strategy'; +import ConditionallyRender from '../../../../common/ConditionallyRender'; +import PercentageCircle from '../../../../common/PercentageCircle/PercentageCircle'; +import FeatureStrategiesSeparator from '../FeatureStrategiesEnvironments/FeatureStrategiesSeparator/FeatureStrategiesSeparator'; +import { useStyles } from './FeatureStrategyExecution.styles'; +import FeatureStrategyExecutionConstraint from './FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint'; +import FeatureStrategyExecutionChips from './FeatureStrategyExecutionChips/FeatureStrategyExecutionChips'; + +interface IFeatureStrategiesExecutionProps { + parameters: IParameter; + constraints?: IConstraint[]; +} + +const FeatureStrategyExecution = ({ + parameters, + constraints = [], +}: IFeatureStrategiesExecutionProps) => { + const styles = useStyles(); + + if (!parameters) return null; + + const renderConstraints = () => { + return constraints.map((constraint, index) => { + if (index !== constraints.length - 1) { + return ( + + + + + ); + } + return ( + + ); + }); + }; + + const renderParameters = () => { + return Object.keys(parameters).map((key, index) => { + switch (key) { + case 'rollout': + case 'Rollout': + return ( + +

+ {parameters[key]}% of your user base{' '} + {constraints.length > 0 + ? 'who match constraints' + : ''}{' '} + are included. +

+ + +
+ ); + case 'userIds': + case 'UserIds': + const users = parameters[key] + .split(',') + .filter((userId: string) => userId); + + return ( + + ); + case 'hostNames': + case 'HostNames': + const hosts = parameters[key] + .split(',') + .filter((hosts: string) => hosts); + + return ( + + ); + case 'IPs': + const IPs = parameters[key] + .split(',') + .filter((hosts: string) => hosts); + + return ( + + ); + default: + return null; + } + }); + }; + + return ( + <> + 0} + show={ +
+

Enabled for match:

+ {renderConstraints()} + +
+ } + /> + {renderParameters()} + + ); +}; + +export default FeatureStrategyExecution; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.styles.ts new file mode 100644 index 0000000000..c96e8cfcd8 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.styles.ts @@ -0,0 +1,11 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { textAlign: 'center' }, + chip: { + margin: '0.25rem', + }, + paragraph: { + margin: '0.25rem 0', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.tsx new file mode 100644 index 0000000000..0679b8eb62 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionChips/FeatureStrategyExecutionChips.tsx @@ -0,0 +1,41 @@ +import { Chip } from '@material-ui/core'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; +import { useStyles } from './FeatureStrategyExecutionChips.styles'; + +interface IFeatureStrategyExecutionChipsProps { + value: string[]; + text: string; +} + +const FeatureStrategyExecutionChips = ({ + value, + text, +}: IFeatureStrategyExecutionChipsProps) => { + const styles = useStyles(); + return ( +
+ No {text}s added yet.

} + elseShow={ +
+

+ {value.length}{' '} + {value.length > 1 ? `${text}s` : text} will get + access. +

+ {value.map((userId: string) => ( + + ))} +
+ } + /> +
+ ); +}; + +export default FeatureStrategyExecutionChips; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.styles.ts new file mode 100644 index 0000000000..b19cae9666 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.styles.ts @@ -0,0 +1,27 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + constraint: { + fontSize: theme.fontSizes.smallBody, + alignItems: 'center;', + margin: '0.5rem 0', + display: 'flex', + border: `1px solid ${theme.palette.grey[300]}`, + padding: '0.2rem', + borderRadius: '5px', + width: '100%', + minWidth: '100%', + }, + constraintName: { + minWidth: '100px', + marginRight: '0.5rem', + }, + constraintOperator: { + marginRight: '0.5rem', + }, + constraintValues: { + textOverflow: 'ellipsis', + overflow: 'hidden', + maxWidth: '50%', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.tsx new file mode 100644 index 0000000000..1f1b8bbe64 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecutionConstraint/FeatureStrategyExecutionConstraint.tsx @@ -0,0 +1,32 @@ +import { IConstraint } from '../../../../../../interfaces/strategy'; +import { useStyles } from './FeatureStrategyExecutionConstraint.styles'; + +interface IFeatureStrategyExecutionConstraintProps { + constraint: IConstraint; +} + +const FeatureStrategyExecutionConstraint = ({ + constraint, +}: IFeatureStrategyExecutionConstraintProps) => { + const translateOperator = (operator: string) => { + if (operator === 'IN') { + return 'IS'; + } + return 'IS NOT'; + }; + + const styles = useStyles(); + return ( +
+

{constraint.contextName}

+

+ {translateOperator(constraint.operator)} +

+

+ {constraint.values.join(', ')} +

+
+ ); +}; + +export default FeatureStrategyExecutionConstraint; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/FlexibleStrategy/FlexibleStrategy.tsx new file mode 100644 index 0000000000..5ad30fadf7 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/FlexibleStrategy/FlexibleStrategy.tsx @@ -0,0 +1,130 @@ +import { Tooltip, Typography } from '@material-ui/core'; +import { Info } from '@material-ui/icons'; + +import { IParameter } from '../../../../../../interfaces/strategy'; +import RolloutSlider from '../RolloutSlider/RolloutSlider'; +import Select from '../../../../../common/select'; +import React from 'react'; +import Input from '../../../../../common/Input/Input'; + +const builtInStickinessOptions = [ + { key: 'default', label: 'default' }, + { key: 'userId', label: 'userId' }, + { key: 'sessionId', label: 'sessionId' }, + { key: 'random', label: 'random' }, +]; + +interface IFlexibleStrategyProps { + parameters: IParameter; + updateParameter: (field: string, value: any) => void; + context: any; +} + +const FlexibleStrategy = ({ + updateParameter, + parameters, + context, +}: IFlexibleStrategyProps) => { + const onUpdate = + (field: string) => + ( + e: React.ChangeEvent<{ name?: string; value: unknown }>, + newValue: number + ) => { + updateParameter(field, newValue); + }; + + const updateRollout = ( + e: React.ChangeEvent<{}>, + value: number | number[] + ) => { + updateParameter('rollout', value); + }; + + const resolveStickiness = () => + builtInStickinessOptions.concat( + context + .filter(c => c.stickiness) + .filter( + c => !builtInStickinessOptions.find(s => s.key === c.name) + ) + .map(c => ({ key: c.name, label: c.name })) + ); + + const stickinessOptions = resolveStickiness(); + + const rollout = parameters.rollout !== undefined ? parameters.rollout : 100; + const stickiness = parameters.stickiness; + const groupId = parameters.groupId; + + return ( +
+ +
+
+ + + Stickiness + + + + onUpdate('groupId')(e, e.target.value)} + /> +
+
+ ); +}; + +export default FlexibleStrategy; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.styles.ts new file mode 100644 index 0000000000..51225ad6c0 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.styles.ts @@ -0,0 +1,13 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + helpText: { + color: 'rgba(0, 0, 0, 0.54)', + fontSize: theme.fontSizes.smallerBody, + lineHeight: '14px', + margin: '0.5rem 0', + }, + generalSection: { + margin: '1rem 0', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.tsx new file mode 100644 index 0000000000..abf4f2d0ca --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/GeneralStrategy/GeneralStrategy.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { + Switch, + FormControlLabel, + Tooltip, + TextField, +} from '@material-ui/core'; + +import StrategyInputList from '../StrategyInputList/StrategyInputList'; +import RolloutSlider from '../RolloutSlider/RolloutSlider'; +import { + IParameter, + IFeatureStrategy, +} from '../../../../../../interfaces/strategy'; +import { useStyles } from './GeneralStrategy.styles'; + +interface IGeneralStrategyProps { + parameters: IParameter; + strategyDefinition: IFeatureStrategy; + updateParameter: () => void; + editable: boolean; +} + +const GeneralStrategy = ({ + parameters, + strategyDefinition, + updateParameter, + editable, +}: IGeneralStrategyProps) => { + const styles = useStyles(); + const onChangeTextField = (field, evt) => { + const { value } = evt.currentTarget; + + evt.preventDefault(); + updateParameter(field, value); + }; + + const onChangePercentage = (field, evt, newValue) => { + evt.preventDefault(); + updateParameter(field, newValue); + }; + + const handleSwitchChange = (key, currentValue) => { + const value = currentValue === 'true' ? 'false' : 'true'; + updateParameter(key, value); + }; + + if ( + strategyDefinition?.parameters && + strategyDefinition?.parameters.length > 0 + ) { + return strategyDefinition.parameters.map( + ({ name, type, description, required }) => { + let value = parameters[name]; + + if (type === 'percentage') { + if ( + value == null || + (typeof value === 'string' && value === '') + ) { + value = 0; + } + return ( +
+
+ + {description && ( +

{description}

+ )} +
+ ); + } else if (type === 'list') { + let list = []; + if (typeof value === 'string') { + list = value.trim().split(',').filter(Boolean); + } + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + } else if (type === 'number') { + const regex = new RegExp('^\\d+$'); + const error = value.length > 0 ? !regex.test(value) : false; + + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + } else if (type === 'boolean') { + return ( +
+ + + } + /> + +
+ ); + } else { + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + } + } + ); + } + return null; +}; + +export default GeneralStrategy; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/RolloutSlider/RolloutSlider.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/RolloutSlider/RolloutSlider.tsx new file mode 100644 index 0000000000..ec95661f44 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/RolloutSlider/RolloutSlider.tsx @@ -0,0 +1,111 @@ +import { makeStyles, withStyles } from '@material-ui/core/styles'; +import { Slider, Typography } from '@material-ui/core'; + +const StyledSlider = withStyles({ + root: { + height: 8, + }, + thumb: { + height: 24, + width: 24, + backgroundColor: '#fff', + border: '2px solid currentColor', + marginTop: -8, + marginLeft: -12, + '&:focus, &:hover, &$active': { + boxShadow: 'inherit', + }, + }, + active: {}, + valueLabel: { + left: 'calc(-50% + 4px)', + }, + track: { + height: 8, + borderRadius: 4, + }, + rail: { + height: 8, + borderRadius: 4, + }, +})(Slider); + +const useStyles = makeStyles(theme => ({ + slider: { + width: 450, + maxWidth: '100%', + }, + margin: { + height: theme.spacing(3), + }, +})); + +const marks = [ + { + value: 0, + label: '0%', + }, + { + value: 25, + label: '25%', + }, + { + value: 50, + label: '50%', + }, + { + value: 75, + label: '75%', + }, + { + value: 100, + label: '100%', + }, +]; + +interface IRolloutSliderProps { + name: string; + minLabel?: string; + maxLabel?: string; + value: number; + onChange: (e: React.ChangeEvent<{}>, newValue: number | number[]) => void; + disabled?: boolean; +} + +const RolloutSlider = ({ + name, + value, + onChange, + disabled = false, +}: IRolloutSliderProps) => { + const classes = useStyles(); + + const valuetext = (value: number) => `${value}%`; + + return ( +
+ + {name} + +
+ +
+ ); +}; + +export default RolloutSlider; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyConstraints/StrategyConstraints.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyConstraints/StrategyConstraints.tsx new file mode 100644 index 0000000000..a3c1b38871 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyConstraints/StrategyConstraints.tsx @@ -0,0 +1,125 @@ +import { Button, Tooltip, Typography } from '@material-ui/core'; +import { Info } from '@material-ui/icons'; + +import { IConstraint } from '../../../../../../interfaces/strategy'; +import { useCommonStyles } from '../../../../../../common.styles'; +import useUiConfig from '../../../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import { C } from '../../../../../common/flags'; +import useUnleashContext from '../../../../../../hooks/api/getters/useUnleashContext/useUnleashContext'; +import StrategyConstraintInputField from '../../../../strategy/StrategyConstraint/StrategyConstraintInputField'; +import { useEffect } from 'react'; + +interface IStrategyConstraintProps { + constraints: IConstraint[]; + updateConstraints: (constraints: IConstraint[]) => void; + constraintError: string; + setConstraintError: () => void; +} + +const StrategyConstraints: React.FC = ({ + constraints, + updateConstraints, + constraintError, + setConstraintError, +}) => { + const { uiConfig } = useUiConfig(); + const { context } = useUnleashContext(); + const commonStyles = useCommonStyles(); + + useEffect(() => { + if (constraints.length === 0) { + addConstraint(); + } + /* eslint-disable-next-line */ + }, []); + + const contextFields = context; + + const enabled = uiConfig.flags[C]; + const contextNames = contextFields.map(context => context.name); + + const onClick = evt => { + evt.preventDefault(); + addConstraint(); + }; + + const addConstraint = () => { + const updatedConstraints = [...constraints]; + updatedConstraints.push(createConstraint()); + updateConstraints(updatedConstraints); + }; + + const createConstraint = () => { + return { + contextName: contextNames[0], + operator: 'IN', + values: [], + }; + }; + + const removeConstraint = index => evt => { + evt.preventDefault(); + const updatedConstraints = [...constraints]; + updatedConstraints.splice(index, 1); + + updateConstraints(updatedConstraints); + }; + + const updateConstraint = index => (value, field) => { + const updatedConstraints = [...constraints]; + const constraint = updatedConstraints[index]; + constraint[field] = value; + updateConstraints(updatedConstraints); + }; + + if (!enabled) { + return null; + } + + return ( +
+ + Use context fields to constrain the activation strategy. + + } + > + + {'Constraints '} + + + + + + + {constraints.map((c, index) => ( + + ))} + +
+ + + +
+ ); +}; + +export default StrategyConstraints; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyInputList/StrategyInputList.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyInputList/StrategyInputList.tsx new file mode 100644 index 0000000000..d86b1738ed --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/StrategyInputList/StrategyInputList.tsx @@ -0,0 +1,111 @@ +import React, { ChangeEvent, useState } from 'react'; +import { Button, Chip, TextField, Typography } from '@material-ui/core'; +import { Add } from '@material-ui/icons'; +import ConditionallyRender from '../../../../../common/ConditionallyRender'; + +interface IStrategyInputList { + name: string; + list: string[]; + setConfig: () => void; + disabled: boolean; +} + +const StrategyInputList = ({ + name, + list, + setConfig, + disabled, +}: IStrategyInputList) => { + const [input, setInput] = useState(''); + const ENTERKEY = 'Enter'; + + const onBlur = (e: ChangeEvent) => { + setValue(e); + }; + + const onKeyDown = (e: ChangeEvent) => { + if (e?.key === ENTERKEY) { + setValue(e); + e.preventDefault(); + e.stopPropagation(); + } + }; + + const setValue = (evt: ChangeEvent) => { + evt.preventDefault(); + const value = evt.target.value; + + if (value) { + const newValues = value + .split(/,\s*/) + .filter(a => !list.includes(a)); + if (newValues.length > 0) { + const newList = list.concat(newValues).filter(a => a); + setConfig(name, newList.join(',')); + } + setInput(''); + } + }; + + const onClose = (index: number) => { + list[index] = null; + setConfig( + name, + list.length === 1 ? '' : list.filter(Boolean).join(',') + ); + }; + + const onChange = e => { + setInput(e.currentTarget.value); + }; + + return ( +
+ List of {name} +
+ {list.map((entryValue, index) => ( + onClose(index)} + title="Remove value" + /> + ))} +
+ + + +
+ } + /> + + ); +}; + +export default StrategyInputList; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/UserWithIdStrategy/UserWithId.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/UserWithIdStrategy/UserWithId.tsx new file mode 100644 index 0000000000..ee9b7360fb --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/common/UserWithIdStrategy/UserWithId.tsx @@ -0,0 +1,34 @@ +import { IParameter } from '../../../../../../interfaces/strategy'; +import StrategyInputList from '../StrategyInputList/StrategyInputList'; + +interface IUserWithIdStrategyProps { + parameters: IParameter; + updateParameter: (field: string, value: any) => void; + editable: boolean; +} + +const UserWithIdStrategy = ({ + editable, + parameters, + updateParameter, +}: IUserWithIdStrategyProps) => { + const value = parameters.userIds; + + let list: string[] = []; + if (typeof value === 'string') { + list = value.trim().split(',').filter(Boolean); + } + + return ( +
+ +
+ ); +}; + +export default UserWithIdStrategy; diff --git a/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts new file mode 100644 index 0000000000..c94e87b054 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts @@ -0,0 +1,28 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { borderRadius: '10px', boxShadow: 'none', display: 'flex' }, + header: { + backgroundColor: '#fff', + borderRadius: '10px', + marginBottom: '1rem', + }, + innerContainer: { padding: '2rem' }, + separator: { + width: '100%', + backgroundColor: theme.palette.grey[200], + height: '1px', + }, + tabContainer: { + padding: '1rem 2rem', + }, + tabButton: { + textTransform: 'none', + width: 'auto', + fontSize: '1rem', + }, + featureViewHeader: { + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx index 7c109b12cc..24f55dec1b 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx @@ -1,19 +1,93 @@ +import { Tabs, Tab } from '@material-ui/core'; import { useParams } from 'react-router-dom'; import useFeature from '../../../hooks/api/getters/useFeature/useFeature'; +import useTabs from '../../../hooks/useTabs'; +import { IFeatureViewParams } from '../../../interfaces/params'; +import TabPanel from '../../common/TabNav/TabPanel'; +import FeatureStrategies from './FeatureStrategies/FeatureStrategies'; +import { useStyles } from './FeatureView2.styles'; import FeatureViewEnvironment from './FeatureViewEnvironment/FeatureViewEnvironment'; import FeatureViewMetaData from './FeatureViewMetaData/FeatureViewMetaData'; const FeatureView2 = () => { - const { projectId, featureId } = useParams(); + const { projectId, featureId } = useParams(); const { feature } = useFeature(projectId, featureId); + const { a11yProps, activeTab, setActiveTab } = useTabs(0); + const styles = useStyles(); + + const renderOverview = () => { + return ( +
+ +
+ {feature?.environments.map(env => { + return ( + + ); + })} +
+
+ ); + }; + + const tabData = [ + { title: 'Overview', component: renderOverview() }, + { title: 'Strategies', component: }, + ]; + + const renderTabs = () => { + return tabData.map((tab, index) => { + return ( + setActiveTab(index)} + className={styles.tabButton} + /> + ); + }); + }; + + const renderTabContent = () => { + return tabData.map((tab, index) => { + return ( + + {tab.component} + + ); + }); + }; return ( -
- - {feature.environments.map(env => { - return ; - })} -
+ <> +
+
+

{feature.name}

+
+
+
+ { + setActiveTab(tabId); + }} + indicatorColor="primary" + textColor="primary" + className={styles.tabNavigation} + > + {renderTabs()} + +
+
+ {renderTabContent()} + ); }; diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts index 353de4fdcd..2ca3a68722 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts @@ -4,7 +4,7 @@ export const useStyles = makeStyles(theme => ({ environmentContainer: { alignItems: 'center', width: '100%', - borderRadius: '5px', + borderRadius: '10px', backgroundColor: '#fff', display: 'flex', padding: '1.5rem', diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx index e1f5d6c93f..921f394d3f 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx @@ -4,7 +4,7 @@ import { useStyles } from './FeatureViewEnvironment.styles'; const FeatureViewEnvironment = ({ env }: any) => { const styles = useStyles(); return ( -
+
Toggle in{' '} {env.name} is {env.enabled ? 'enabled' : 'disabled'} diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts index 96ec8c0725..25d5207ca2 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts @@ -2,7 +2,7 @@ import { makeStyles } from '@material-ui/core/styles'; export const useStyles = makeStyles(theme => ({ container: { - borderRadius: '5px', + borderRadius: '10px', backgroundColor: '#fff', display: 'flex', flexDirection: 'column', diff --git a/frontend/src/component/feature/strategy/AddStrategy/utils.js b/frontend/src/component/feature/strategy/AddStrategy/utils.js index 7092ba0f35..136ac6c2b0 100644 --- a/frontend/src/component/feature/strategy/AddStrategy/utils.js +++ b/frontend/src/component/feature/strategy/AddStrategy/utils.js @@ -2,7 +2,7 @@ export const resolveDefaultParamValue = (name, featureToggleName) => { switch (name) { case 'percentage': case 'rollout': - return '100'; + return 100; case 'stickiness': return 'default'; case 'groupId': diff --git a/frontend/src/component/feature/strategy/EditStrategyModal/general-strategy.jsx b/frontend/src/component/feature/strategy/EditStrategyModal/general-strategy.jsx index 72016c2ca5..ace8565053 100644 --- a/frontend/src/component/feature/strategy/EditStrategyModal/general-strategy.jsx +++ b/frontend/src/component/feature/strategy/EditStrategyModal/general-strategy.jsx @@ -1,11 +1,21 @@ import React from 'react'; -import { Switch, FormControlLabel, Tooltip, TextField } from '@material-ui/core'; +import { + Switch, + FormControlLabel, + Tooltip, + TextField, +} from '@material-ui/core'; import strategyInputProps from './strategy-input-props'; import StrategyInputPercentage from './input-percentage'; import StrategyInputList from './input-list'; import styles from '../strategy.module.scss'; -export default function GeneralStrategyInput({ parameters, strategyDefinition, updateParameter, editable }) { +export default function GeneralStrategyInput({ + parameters, + strategyDefinition, + updateParameter, + editable, +}) { const onChangeTextField = (field, evt) => { const { value } = evt.currentTarget; @@ -22,104 +32,121 @@ export default function GeneralStrategyInput({ parameters, strategyDefinition, u const value = currentValue === 'true' ? 'false' : 'true'; updateParameter(key, value); }; + if ( + strategyDefinition.parameters && + strategyDefinition.parameters.length > 0 + ) { + return strategyDefinition.parameters.map( + ({ name, type, description, required }) => { + let value = parameters[name]; - if (strategyDefinition.parameters && strategyDefinition.parameters.length > 0) { - return strategyDefinition.parameters.map(({ name, type, description, required }) => { - let value = parameters[name]; - - if (type === 'percentage') { - if (value == null || (typeof value === 'string' && value === '')) { - value = 0; - } - return ( -
-
- - {description && ( -

- {description} -

- )} -
- ); - } else if (type === 'list') { - let list = []; - if (typeof value === 'string') { - list = value - .trim() - .split(',') - .filter(Boolean); - } - return ( -
- - {description &&

{description}

} -
- ); - } else if (type === 'number') { - const regex = new RegExp('^\\d+$'); - const error = value.length > 0 ? !regex.test(value) : false; - - return ( -
- - {description &&

{description}

} -
- ); - } else if (type === 'boolean') { - return ( -
- - - } + if (type === 'percentage') { + if ( + value == null || + (typeof value === 'string' && value === '') + ) { + value = 0; + } + return ( +
+
+ - -
- ); - } else { - return ( -
- - {description &&

{description}

} -
- ); + {description && ( +

{description}

+ )} +
+ ); + } else if (type === 'list') { + let list = []; + if (typeof value === 'string') { + list = value.trim().split(',').filter(Boolean); + } + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + } else if (type === 'number') { + const regex = new RegExp('^\\d+$'); + const error = value.length > 0 ? !regex.test(value) : false; + + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + } else if (type === 'boolean') { + return ( +
+ + + } + /> + +
+ ); + } else { + return ( +
+ + {description && ( +

{description}

+ )} +
+ ); + } } - }); + ); } return null; } diff --git a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap index ed8e643691..eaa6b76035 100644 --- a/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap +++ b/frontend/src/component/feature/variant/__tests__/__snapshots__/update-variant-component-test.jsx.snap @@ -462,7 +462,6 @@ exports[`renders correctly with with variants 1`] = `
{ - expect(baseRoutes).toHaveLength(38); + expect(baseRoutes).toHaveLength(39); expect(baseRoutes).toMatchSnapshot(); }); diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 63175e8289..7f507c98dc 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -43,6 +43,7 @@ import RedirectArchive from '../feature/RedirectArchive/RedirectArchive'; import EnvironmentList from '../environments/EnvironmentList/EnvironmentList'; import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment'; import FeatureView2 from '../feature/FeatureView2/FeatureView2'; +import FeatureStrategies from '../feature/FeatureView2/FeatureStrategies/FeatureStrategies'; export const routes = [ // Features @@ -242,6 +243,16 @@ export const routes = [ layout: 'main', menu: {}, }, + { + path: '/projects/:projectId/features2/:featureId/strategies', + parent: '/projects', + title: 'FeatureView2', + component: FeatureStrategies, + type: 'protected', + layout: 'main', + flags: E, + menu: {}, + }, { path: '/projects/:projectId/features2/:featureId', parent: '/projects', diff --git a/frontend/src/constants/environmentTypes.ts b/frontend/src/constants/environmentTypes.ts new file mode 100644 index 0000000000..e0a3cb9e60 --- /dev/null +++ b/frontend/src/constants/environmentTypes.ts @@ -0,0 +1 @@ +export const PRODUCTION = 'production'; diff --git a/frontend/src/contexts/FeatureStrategiesUIContext.ts b/frontend/src/contexts/FeatureStrategiesUIContext.ts new file mode 100644 index 0000000000..f0395e3204 --- /dev/null +++ b/frontend/src/contexts/FeatureStrategiesUIContext.ts @@ -0,0 +1,21 @@ +import React from 'react'; +import { IFeatureEnvironment } from '../interfaces/featureToggle'; +import { IStrategyPayload } from '../interfaces/strategy'; + +interface IFeatureStrategiesUIContext { + configureNewStrategy: IStrategyPayload | null; + setConfigureNewStrategy: React.Dispatch< + React.SetStateAction + >; + setActiveEnvironment: React.Dispatch< + React.SetStateAction + >; + activeEnvironment: IFeatureEnvironment | null; + expandedSidebar: boolean; + setExpandedSidebar: React.Dispatch>; +} + +const FeatureStrategiesUIContext = + React.createContext(null); + +export default FeatureStrategiesUIContext; diff --git a/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts b/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts new file mode 100644 index 0000000000..75e972b45c --- /dev/null +++ b/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts @@ -0,0 +1,85 @@ +import { IStrategyPayload } from '../../../../interfaces/strategy'; +import useAPI from '../useApi/useApi'; + +const useFeatureStrategyApi = () => { + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + }); + + const addStrategyToFeature = async ( + projectId: string, + featureId: string, + environmentId: string, + payload: IStrategyPayload + ) => { + const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`; + const req = createRequest( + path, + { method: 'POST', body: JSON.stringify(payload) }, + 'addStrategyToFeature' + ); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + const deleteStrategyFromFeature = async ( + projectId: string, + featureId: string, + environmentId: string, + strategyId: string + ) => { + const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`; + const req = createRequest( + path, + { method: 'DELETE' }, + 'deleteStrategyFromFeature' + ); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + const updateStrategyOnFeature = async ( + projectId: string, + featureId: string, + environmentId: string, + strategyId: string, + payload: IStrategyPayload + ) => { + const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`; + const req = createRequest( + path, + { method: 'PUT', body: JSON.stringify(payload) }, + 'updateStrategyOnFeature' + ); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + return { + addStrategyToFeature, + updateStrategyOnFeature, + deleteStrategyFromFeature, + loading, + errors, + }; +}; + +export default useFeatureStrategyApi; diff --git a/frontend/src/hooks/api/getters/useFeature/useFeature.ts b/frontend/src/hooks/api/getters/useFeature/useFeature.ts index 59bde50a74..e65e812b8c 100644 --- a/frontend/src/hooks/api/getters/useFeature/useFeature.ts +++ b/frontend/src/hooks/api/getters/useFeature/useFeature.ts @@ -5,7 +5,18 @@ import { formatApiPath } from '../../../../utils/format-path'; import { IFeatureToggle } from '../../../../interfaces/featureToggle'; import { defaultFeature } from './defaultFeature'; -const useFeature = (projectId: string, id: string) => { +interface IUseFeatureOptions { + refreshInterval?: number; + revalidateOnFocus?: boolean; + revalidateOnReconnect?: boolean; + revalidateIfStale?: boolean; +} + +const useFeature = ( + projectId: string, + id: string, + options: IUseFeatureOptions +) => { const fetcher = () => { const path = formatApiPath( `api/admin/projects/${projectId}/features/${id}` @@ -15,31 +26,28 @@ const useFeature = (projectId: string, id: string) => { }).then(res => res.json()); }; - const KEY = `api/admin/projects/${projectId}/features/${id}`; + const FEATURE_CACHE_KEY = `api/admin/projects/${projectId}/features/${id}`; + + const { data, error } = useSWR(FEATURE_CACHE_KEY, fetcher, { + ...options, + }); - const { data, error } = useSWR(KEY, fetcher); const [loading, setLoading] = useState(!error && !data); const refetch = () => { - mutate(KEY); + mutate(FEATURE_CACHE_KEY); }; useEffect(() => { setLoading(!error && !data); }, [data, error]); - let feature = defaultFeature; - if (data) { - if (data.environments) { - feature = data; - } - } - return { - feature, + feature: data || defaultFeature, error, loading, refetch, + FEATURE_CACHE_KEY, }; }; diff --git a/frontend/src/hooks/api/getters/useFeatureStrategy/useFeatureStrategy.ts b/frontend/src/hooks/api/getters/useFeatureStrategy/useFeatureStrategy.ts new file mode 100644 index 0000000000..60a870ec00 --- /dev/null +++ b/frontend/src/hooks/api/getters/useFeatureStrategy/useFeatureStrategy.ts @@ -0,0 +1,67 @@ +import useSWR, { mutate } from 'swr'; +import { useState, useEffect } from 'react'; + +import { formatApiPath } from '../../../../utils/format-path'; +import { IFeatureStrategy } from '../../../../interfaces/strategy'; + +interface IUseFeatureOptions { + refreshInterval?: number; + revalidateOnFocus?: boolean; + revalidateOnReconnect?: boolean; + revalidateIfStale?: boolean; + revalidateOnMount?: boolean; +} + +const useFeatureStrategy = ( + projectId: string, + featureId: string, + environmentId: string, + strategyId: string, + options: IUseFeatureOptions +) => { + const fetcher = () => { + const path = formatApiPath( + `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}` + ); + return fetch(path, { + method: 'GET', + }).then(res => res.json()); + }; + + const FEATURE_STRATEGY_CACHE_KEY = strategyId; + + const { data, error } = useSWR( + FEATURE_STRATEGY_CACHE_KEY, + fetcher, + { + ...options, + } + ); + + const [loading, setLoading] = useState(!error && !data); + + const refetch = () => { + mutate(FEATURE_STRATEGY_CACHE_KEY); + }; + + useEffect(() => { + setLoading(!error && !data); + }, [data, error]); + + return { + strategy: + data || + ({ + constraints: [], + parameters: {}, + id: '', + name: '', + } as IFeatureStrategy), + error, + loading, + refetch, + FEATURE_STRATEGY_CACHE_KEY, + }; +}; + +export default useFeatureStrategy; diff --git a/frontend/src/hooks/api/getters/useStrategies/useStrategies.ts b/frontend/src/hooks/api/getters/useStrategies/useStrategies.ts new file mode 100644 index 0000000000..9638c58409 --- /dev/null +++ b/frontend/src/hooks/api/getters/useStrategies/useStrategies.ts @@ -0,0 +1,40 @@ +import useSWR, { mutate } from 'swr'; +import { useEffect, useState } from 'react'; +import { formatApiPath } from '../../../../utils/format-path'; +import { IStrategy } from '../../../../interfaces/strategy'; + +export const STRATEGIES_CACHE_KEY = 'api/admin/strategies'; + +const useStrategies = () => { + const fetcher = () => { + const path = formatApiPath(`api/admin/strategies`); + + return fetch(path, { + method: 'GET', + credentials: 'include', + }).then(res => res.json()); + }; + + const { data, error } = useSWR<{ strategies: IStrategy[] }>( + STRATEGIES_CACHE_KEY, + fetcher + ); + const [loading, setLoading] = useState(!error && !data); + + const refetch = () => { + mutate(STRATEGIES_CACHE_KEY); + }; + + useEffect(() => { + setLoading(!error && !data); + }, [data, error]); + + return { + strategies: data?.strategies || [], + error, + loading, + refetch, + }; +}; + +export default useStrategies; diff --git a/frontend/src/hooks/api/getters/useUnleashContext/useUnleashContext.ts b/frontend/src/hooks/api/getters/useUnleashContext/useUnleashContext.ts new file mode 100644 index 0000000000..35a99b661e --- /dev/null +++ b/frontend/src/hooks/api/getters/useUnleashContext/useUnleashContext.ts @@ -0,0 +1,40 @@ +import useSWR, { mutate } from 'swr'; +import { useState, useEffect } from 'react'; +import { formatApiPath } from '../../../../utils/format-path'; + +const useUnleashContext = (revalidate = true) => { + const fetcher = () => { + const path = formatApiPath(`api/admin/context`); + return fetch(path, { + method: 'GET', + }).then(res => res.json()); + }; + + const CONTEXT_CACHE_KEY = 'api/admin/context'; + + const { data, error } = useSWR(CONTEXT_CACHE_KEY, fetcher, { + revalidateOnFocus: revalidate, + revalidateOnReconnect: revalidate, + revalidateIfStale: revalidate, + }); + + const [loading, setLoading] = useState(!error && !data); + + const refetch = () => { + mutate(CONTEXT_CACHE_KEY); + }; + + useEffect(() => { + setLoading(!error && !data); + }, [data, error]); + + return { + context: data || [], + error, + loading, + refetch, + CONTEXT_CACHE_KEY, + }; +}; + +export default useUnleashContext; diff --git a/frontend/src/hooks/useTabs.ts b/frontend/src/hooks/useTabs.ts new file mode 100644 index 0000000000..4a00da83bb --- /dev/null +++ b/frontend/src/hooks/useTabs.ts @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +const useTabs = (startingIndex: number = 0) => { + const [activeTab, setActiveTab] = useState(startingIndex); + + const a11yProps = (index: number) => ({ + id: `tab-${index}`, + 'aria-controls': `tabpanel-${index}`, + }); + + return { activeTab, setActiveTab, a11yProps }; +}; + +export default useTabs; diff --git a/frontend/src/hooks/useToast.tsx b/frontend/src/hooks/useToast.tsx index 7c9534f3ed..3c388fc79d 100644 --- a/frontend/src/hooks/useToast.tsx +++ b/frontend/src/hooks/useToast.tsx @@ -1,15 +1,21 @@ import { useState } from 'react'; import Toast from '../component/common/Toast/Toast'; +export interface IToast { + show: boolean; + type: string; + text: string; +} + const useToast = () => { - const [toastData, setToastData] = useState({ + const [toastData, setToastData] = useState({ show: false, type: 'success', text: '', }); const hideToast = () => { - setToastData(prev => ({ ...prev, show: false })); + setToastData((prev: IToast) => ({ ...prev, show: false })); }; const toast = ( nameMapping[strategyName]; +export const getHumanReadbleStrategy = strategyName => + nameMapping[strategyName]; export const getHumanReadbleStrategyName = strategyName => { const humanReadableStrategy = nameMapping[strategyName]; @@ -44,3 +55,18 @@ export const getHumanReadbleStrategyName = strategyName => { } return strategyName; }; + +export const getFeatureStrategyIcon = strategyName => { + switch (strategyName) { + case 'remoteAddress': + return LanguageIcon; + case 'flexibleRollout': + return DonutLarge; + case 'userWithId': + return PeopleIcon; + case 'applicationHostname': + return LocationOnIcon; + default: + return MapIcon; + } +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fa9451b337..4e1c7292a9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3495,9 +3495,11 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001181: - version "1.0.30001207" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001207.tgz" - integrity sha512-UPQZdmAsyp2qfCTiMU/zqGSWOYaY9F9LL61V8f+8MrubsaDGpaHD9HRV/EWZGULZn0Hxu48SKzI5DgFwTvHuYw== + version "1.0.30001260" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001260.tgz" + integrity sha512-Fhjc/k8725ItmrvW5QomzxLeojewxvqiYCKeFcfFEhut28IVLdpHU19dneOmltZQIE5HNbawj1HYD+1f2bM1Dg== + dependencies: + nanocolors "^0.1.0" capture-exit@^2.0.0: version "2.0.0" @@ -8214,6 +8216,11 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== +nanocolors@^0.1.0: + version "0.1.12" + resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.1.12.tgz#8577482c58cbd7b5bb1681db4cf48f11a87fd5f6" + integrity sha512-2nMHqg1x5PU+unxX7PGY7AuYxl2qDx7PSrTRjizr8sxdd3l/3hBuWWaki62qmtYm2U5i4Z5E7GbjlyDFhs9/EQ== + nanoid@^3.1.22: version "3.1.22" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz"