1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-13 11:17:26 +02:00

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 <chriswk@getunleash.ai>

* Update src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategyExecution/FeatureStrategyExecution.tsx

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>

* Update src/component/feature/strategy/EditStrategyModal/general-strategy.jsx

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
Co-authored-by: UnleashTeam <79193084+UnleashTeam@users.noreply.github.com>
This commit is contained in:
Fredrik Strand Oseberg 2021-09-27 13:35:32 +02:00 committed by GitHub
parent 139098fda9
commit 27988e4b30
81 changed files with 3801 additions and 176 deletions

View File

@ -0,0 +1,17 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="2.80469" width="35.3711" height="5.05301" rx="1.95354" fill="#635DC5"/>
<g filter="url(#filter0_d)">
<circle cx="21.3343" cy="5.05301" r="5.05301" fill="#635DC5"/>
</g>
<defs>
<filter id="filter0_d" x="16.2812" y="0" width="15.3155" height="15.3155" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="2.60471" dy="2.60471"/>
<feGaussianBlur stdDeviation="1.30236"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.225 0 0 0 0 0.214687 0 0 0 0 0.214687 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 962 B

View File

@ -31,6 +31,9 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
setStyles(enter);
}, 50);
} else {
if (!leave) {
setShow(false);
}
setStyles(leave);
}
}

View File

@ -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%)',

View File

@ -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<IDialogue> = ({
children,
open,
onClick,
@ -34,7 +47,7 @@ const Dialogue = ({
>
<DialogTitle className={styles.dialogTitle}>{title}</DialogTitle>
<ConditionallyRender
condition={children}
condition={Boolean(children)}
show={
<DialogContent className={styles.dialogContentPadding}>
{children}
@ -44,7 +57,7 @@ const Dialogue = ({
<DialogActions>
<ConditionallyRender
condition={onClick}
condition={Boolean(onClick)}
show={
<Button
color="primary"
@ -59,7 +72,7 @@ const Dialogue = ({
/>
<ConditionallyRender
condition={onClose}
condition={Boolean(onClose)}
show={
<Button onClick={onClose}>
{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;

View File

@ -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 (
<div
style={{
...circle,
...styles,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
100%
</div>
);
}
return <div style={{ ...circle, ...styles }} />;
};
export default PercentageCircle;

View File

@ -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) => (
<TabPanel key={`tab_panel_${index}`} value={activeTab} index={index}>
<TabPanel
key={`tab_panel_${index}`}
value={activeTab}
index={index}
>
{tab.component}
</TabPanel>
));
return (
<>
<Paper className={styles.tabNav}>
<Paper className={classnames(styles.tabNav, navClass)}>
<Tabs
value={activeTab}
onChange={(_, tabId) => {

View File

@ -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<ISelectMenuProps> = ({
name,
value,
label,
value = '',
label = '',
options,
onChange,
id,
@ -16,7 +36,7 @@ const SelectMenu = ({
}) => {
const renderSelectItems = () =>
options.map(option => (
<MenuItem key={option.key} value={option.key} title={option.title}>
<MenuItem key={option.key} value={option.key} title={option.title || ''}>
{option.label}
</MenuItem>
));
@ -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;

View File

@ -0,0 +1,5 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: { borderRadius: '10px', boxShadow: 'none', display: 'flex' },
}));

View File

@ -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 (
<Paper className={styles.container}>
<FeatureStrategiesUIProvider>
<FeatureStrategiesList />
<FeatureStrategiesEnvironments />
</FeatureStrategiesUIProvider>
</Paper>
);
};
export default FeatureStrategies;

View File

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

View File

@ -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 (
<Fragment key={strategy.id}>
<FeatureEnvironmentStrategyExecutionWrapper
strategyId={strategy.id}
/>
<FeatureEnvironmentStrategyExecutionSeparator />
</Fragment>
);
}
return (
<FeatureEnvironmentStrategyExecutionWrapper
strategyId={strategy.id}
key={strategy.id}
/>
);
});
};
return (
<div className={styles.container}>
<h3 className={styles.header}>Strategy execution in {env.name}</h3>
{renderStrategies()}
</div>
);
};
export default FeatureEnvironmentStrategyExecution;

View File

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

View File

@ -0,0 +1,18 @@
import FeatureStrategiesSeparator from '../../FeatureStrategiesSeparator/FeatureStrategiesSeparator';
import { useStyles } from './FeatureEnvironmentStrategyExecutionSeparator.styles';
const FeatureEnvironmentStrategyExecutionSeparator = () => {
const styles = useStyles();
return (
<div className={styles.container}>
<div className={styles.separatorBorder} />
<div className={styles.textContainer}>
<div className={styles.textPositioning}>
<FeatureStrategiesSeparator text="OR" />
</div>
</div>
</div>
);
};
export default FeatureEnvironmentStrategyExecutionSeparator;

View File

@ -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<IFeatureViewParams>();
const { activeEnvironment } = useContext(FeatureStrategiesUIContext);
const { strategy } = useFeatureStrategy(
projectId,
featureId,
activeEnvironment.name,
strategyId,
{
revalidateOnMount: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnFocus: false,
}
);
return (
<div
style={{
padding: '1rem',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FeatureStrategyExecution
constraints={strategy.constraints}
parameters={strategy.parameters}
/>
</div>
);
};
export default FeatureEnvironmentStrategyExecutionWrapper;

View File

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

View File

@ -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<React.SetStateAction<IToastType>>;
}
const FeatureStrategiesConfigure = ({
setToastData,
}: IFeatureStrategiesConfigure) => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
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 (
<div className={styles.container}>
<h2 className={styles.header}>
Configuring{' '}
{getHumanReadbleStrategyName(configureNewStrategy.name)} in{' '}
{activeEnvironment.name}
</h2>
<ConditionallyRender
condition={activeEnvironment.enabled}
show={
<Alert severity="warning" className={styles.envWarning}>
This environment is currently enabled. The strategy will
take effect immediately after you save your changes.
</Alert>
}
elseShow={
<Alert severity="warning" className={styles.envWarning}>
This environment is currently disabled. The strategy
will not take effect before you enable the environment
on the feature toggle.
</Alert>
}
/>
<div className={styles.configureContainer}>
<div className={styles.accordionContainer}>
<FeatureStrategyAccordion
strategy={configureNewStrategy}
expanded
hideActions
parameters={strategyParams}
constraints={strategyConstraints}
setStrategyParams={setStrategyParams}
setStrategyConstraints={setStrategyConstraints}
/>
</div>
<div className={styles.executionContainer}>
<FeatureStrategyCreateExecution
parameters={strategyParams}
constraints={strategyConstraints}
/>
</div>
</div>
<div className={styles.buttonContainer}>
<Button
variant="contained"
color="primary"
className={styles.btn}
onClick={resolveSubmit}
>
Save
</Button>
<Button className={styles.btn} onClick={handleCancel}>
Cancel
</Button>
</div>
<FeatureStrategiesProductionGuard
primaryButtonText="Save changes"
show={productionGuard}
onClick={() => {
handleSubmit();
setProductionGuard(false);
}}
onClose={() => setProductionGuard(false)}
/>
</div>
);
};
export default FeatureStrategiesConfigure;

View File

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

View File

@ -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 (
<Fragment key={strategy.id}>
<FeatureStrategyEditable
currentStrategy={strategy}
setDelDialog={setDelDialog}
updateStrategy={resolveUpdateStrategy}
/>
<FeatureStrategiesSeparator text="OR" />
</Fragment>
);
} else {
return (
<FeatureStrategyEditable
key={strategy.id}
setDelDialog={setDelDialog}
currentStrategy={strategy}
updateStrategy={resolveUpdateStrategy}
/>
);
}
});
};
const classes = classnames(styles.container, {
[styles.isOver]: isOver,
});
const strategiesContainerClasses = classnames({
[styles.strategiesContainer]: !expandedSidebar,
});
return (
<ConditionallyRender
condition={!configureNewStrategy}
show={
<div className={classes} ref={drop}>
<div className={strategiesContainerClasses}>
{renderStrategies()}
</div>
{dropboxMarkup}
{toast}
{delDialogueMarkup}
{productionGuardMarkup}
</div>
}
/>
);
};
export default FeatureStrategiesEnvironmentList;

View File

@ -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 (
<Dialogue
title="Are you sure you want to delete this strategy?"
open={show}
primaryButtonText="Delete strategy"
secondaryButtonText="Cancel"
onClick={onClick}
onClose={onClose}
>
<Alert severity="error">
Deleting the strategy will change which users receive access to
the feature.
</Alert>
</Dialogue>
);
};
export default useDeleteStrategyMarkup;

View File

@ -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 (
<ConditionallyRender
condition={expandedSidebar}
show={
<div className={dropboxClasses}>
<p>Drag and drop strategies from the left side menu</p>
<GetApp className={iconClasses} />
</div>
}
/>
);
};
export default useDropboxMarkup;

View File

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

View File

@ -0,0 +1,24 @@
import FeatureStrategiesProductionGuard from '../FeatureStrategiesProductionGuard/FeatureStrategiesProductionGuard';
interface IUseProductionGuardMarkupProps {
show: boolean;
onClick: () => void;
onClose: () => void;
}
const useProductionGuardMarkup = ({
show,
onClick,
onClose,
}: IUseProductionGuardMarkupProps) => {
return (
<FeatureStrategiesProductionGuard
primaryButtonText="Update strategy"
show={show}
onClick={onClick}
onClose={onClose}
/>
);
};
export default useProductionGuardMarkup;

View File

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

View File

@ -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<IFeatureViewParams>();
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 (
<Tab
disabled={configureNewStrategy}
key={`${env.name}_${index}`}
label={env.name}
{...a11yProps(index)}
onClick={() => 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 (
<TabPanel
key={`tab_panel_${index}`}
value={activeTab}
index={index}
>
<div className={tabContentClasses}>
<div className={listContainerClasses}>
<FeatureStrategiesEnvironmentList
strategies={env.strategies}
/>
</div>
<ConditionallyRender
condition={
!expandedSidebar && !configureNewStrategy
}
show={
<FeatureEnvironmentStrategyExecution
strategies={env.strategies}
env={env}
/>
}
/>
</div>
</TabPanel>
);
});
};
const handleRefresh = () => {
setFeatureCache(cloneDeep(feature));
setShowRefreshPrompt(false);
};
const handleCancel = () => {
setShowRefreshPrompt(false);
};
const classes = classNames(styles.container, {
[styles.fullWidth]: !expandedSidebar,
});
return (
<div className={classes}>
<div className={styles.environmentsHeader}>
<h2 className={styles.header}>Environments</h2>
<FeatureStrategiesRefresh
show={showRefreshPrompt}
refresh={handleRefresh}
cancel={handleCancel}
/>
<Button
variant="contained"
color="primary"
onClick={() => setExpandedSidebar(prev => !prev)}
>
{expandedSidebar ? 'Hide sidebar' : 'Add new strategy'}
</Button>
</div>
<div className={styles.tabContainer}>
<Tabs
value={activeTab}
onChange={(_, tabId) => {
setActiveTab(tabId);
setActiveEnvironment(featureCache?.environments[tabId]);
}}
indicatorColor="primary"
textColor="primary"
className={styles.tabNavigation}
>
{renderTabs()}
</Tabs>
</div>
<div>
{renderTabPanels()}
<ConditionallyRender
condition={configureNewStrategy}
show={
<FeatureStrategiesConfigure
setToastData={setToastData}
/>
}
/>
</div>
{toast}
</div>
);
};
export default FeatureStrategiesEnvironments;

View File

@ -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 (
<Dialogue
title="Changing production environment!"
open={show}
primaryButtonText={primaryButtonText}
secondaryButtonText="Cancel"
onClick={onClick}
onClose={onClose}
>
<Alert severity="error">
WARNING. You are about to make changes to a production
environment. These changes will affect your customers.
</Alert>
<p style={{ marginTop: '1rem' }}>
Are you sure you want to proceed?
</p>
</Dialogue>
);
};
export default FeatureStrategiesProductionGuard;

View File

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

View File

@ -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 (
<AnimateOnMount
mounted={show}
enter={styles.fadeInEnter}
start={styles.fadeInStart}
leave={styles.fadeInLeave}
>
<div className={styles.container}>
<p className={styles.refreshHeader}>
NOTE: Updated configuration
</p>
<p className={styles.paragraph}>
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?
</p>
<div className={styles.buttonContainer}>
<Button
onClick={refresh}
variant="contained"
color="primary"
className={styles.mainBtn}
>
Refresh configuration
</Button>
<Button onClick={cancel}>Disregard</Button>
</div>
</div>
</AnimateOnMount>
);
};
export default FeatureStrategiesRefresh;

View File

@ -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 (
<div
style={{
color: theme.palette.primary.main,
padding: '0.1rem 0.25rem',
border: `1px solid ${theme.palette.primary.main}`,
borderRadius: '0.25rem',
maxWidth,
fontSize: theme.fontSizes.smallerBody,
textAlign: 'center',
margin: '0.5rem 0rem 0.5rem 1rem',
backgroundColor: '#fff',
}}
>
{text}
</div>
);
};
export default FeatureStrategiesSeparator;

View File

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

View File

@ -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<React.SetStateAction<any>>;
updateStrategy: (strategy: IFeatureStrategy) => void;
}
const FeatureStrategyEditable = ({
currentStrategy,
updateStrategy,
setDelDialog,
}: IFeatureStrategyEditable) => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { activeEnvironment, featureCache, dirty, setDirty } = useContext(
FeatureStrategiesUIContext
);
const [strategyCache, setStrategyCache] = useState<IFeatureStrategy | null>(
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 (
<div className={styles.editableContainer}>
<ConditionallyRender
condition={dirty[strategy.id]}
show={<div className={styles.unsaved}>Unsaved changes</div>}
/>
<FeatureStrategyAccordion
parameters={parameters}
constraints={constraints}
strategy={strategy}
setStrategyParams={setStrategyParams}
setStrategyConstraints={setStrategyConstraints}
dirty={dirty[strategy.id]}
actions={
<>
<Tooltip title="Delete strategy">
<IconButton
onClick={e => {
e.stopPropagation();
setDelDialog({
strategyId: strategy.id,
show: true,
});
}}
>
<Delete />
</IconButton>
</Tooltip>
<Tooltip title="Copy strategy">
<IconButton
onClick={e => {
e.stopPropagation();
}}
>
<FileCopy />
</IconButton>
</Tooltip>
</>
}
>
<ConditionallyRender
condition={dirty[strategy.id]}
show={
<>
<div className={styles.buttonContainer}>
<Button
variant="contained"
color="primary"
style={{ marginRight: '1rem' }}
onClick={updateFeatureStrategy}
>
Save changes
</Button>
<Button onClick={discardChanges}>
Discard changes
</Button>
</div>
</>
}
/>
</FeatureStrategyAccordion>
</div>
);
};
export default FeatureStrategyEditable;

View File

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

View File

@ -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) => (
<FeatureStrategyCard
key={strategy.name}
configureNewStrategy={!expandedSidebar}
name={strategy.name}
description={strategy.description}
/>
));
};
const classes = classnames(styles.sidebar, {
[styles.sidebarSmall]: !expandedSidebar,
});
return <section className={classes}>{renderStrategies()}</section>;
};
export default FeatureStrategiesList;

View File

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

View File

@ -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 (
<>
<ConditionallyRender
condition={!configureNewStrategy}
show={
<div className={classes} ref={drag}>
<div className={styles.leftSection}>
<div className={styles.iconContainer}>
{<Icon className={styles.icon} />}
</div>
</div>
<div className={styles.rightSection}>
<Tooltip title={readableName}>
<p className={styles.title}>{readableName}</p>
</Tooltip>
<p className={styles.description}>{description}</p>
</div>
</div>
}
elseShow={
<IconButton disabled>
<Icon />
</IconButton>
}
/>
</>
);
};
export default FeatureStrategyCard;

View File

@ -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<IStrategyPayload | null>(null);
const [activeEnvironment, setActiveEnvironment] =
useState<IFeatureEnvironment | null>(null);
const [expandedSidebar, setExpandedSidebar] = useState(false);
const [featureCache, setFeatureCache] = useState<IFeatureToggle | null>(
null
);
const [dirty, setDirty] = useState({});
const context = {
configureNewStrategy,
setConfigureNewStrategy,
setActiveEnvironment,
activeEnvironment,
expandedSidebar,
setExpandedSidebar,
featureCache,
setFeatureCache,
setDirty,
dirty,
};
return (
<FeatureStrategiesUIContext.Provider value={context}>
{children}
</FeatureStrategiesUIContext.Provider>
);
};
export default FeatureStrategiesUIProvider;

View File

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

View File

@ -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<React.SetStateAction<IConstraint[]>>;
updateStrategy?: (strategyId: string) => void;
actions?: JSX.Element | JSX.Element[];
}
const FeatureStrategyAccordion: React.FC<IFeatureStrategyAccordionProps> = ({
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 (
<div className={styles.container}>
<Accordion className={styles.accordion} defaultExpanded={expanded}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="strategy-content"
id={strategy.name}
>
<div className={styles.accordionSummary}>
<p className={styles.accordionHeader}>
<Icon className={styles.icon} /> {strategyName}
</p>
<ConditionallyRender
condition={Boolean(parameters?.rollout)}
show={
<p className={styles.rollout}>
Rolling out to {parameters?.rollout}%
</p>
}
/>
<div className={styles.accordionActions}>{actions}</div>
</div>
</AccordionSummary>
<AccordionDetails>
<FeatureStrategyAccordionBody
strategy={{ ...strategy, parameters }}
updateParameters={updateParameters}
updateConstraints={updateConstraints}
constraints={constraints}
setStrategyConstraints={setStrategyConstraints}
>
{children}
</FeatureStrategyAccordionBody>
</AccordionDetails>
</Accordion>
</div>
);
};
export default FeatureStrategyAccordion;

View File

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

View File

@ -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<React.SetStateAction<IConstraint[]>>;
}
const FeatureStrategyAccordionBody: React.FC<IFeatureStrategyAccordionBodyProps> =
({
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 (
<p className={styles.noConstraints}>
No constraints configured
</p>
);
}
return constraints.map((constraint, index) => {
return (
<div
key={`${constraint.contextName}-${index}`}
className={styles.constraint}
>
<span className={styles.contextName}>
{constraint.contextName}
</span>
<FeatureStrategiesSeparator
text={constraint.operator}
maxWidth="none"
/>
<span className={styles.values}>
{constraint.values.join(', ')}
</span>
</div>
);
});
};
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 (
<div>
<ConditionallyRender
condition={ON}
show={
<>
<p className={styles.constraintHeader}>
Constraints
</p>
{renderConstraints()}
<Button
className={styles.addConstraintBtn}
onClick={toggleConstraints}
>
+ Add constraint
</Button>
</>
}
/>
<Dialogue
title="Define constraints"
open={showConstraints}
onClick={saveConstraintsLocally}
primaryButtonText="Update constraints"
secondaryButtonText="Cancel"
onClose={closeConstraintDialog}
fullWidth
maxWidth="md"
>
<StrategyConstraints
updateConstraints={updateConstraints}
constraints={constraints || []}
constraintError={constraintError}
setConstraintError={setConstraintError}
/>
</Dialogue>
<Type
parameters={parameters}
updateParameter={updateParameters}
strategyDefinition={definition}
context={context}
editable
/>
{children}
</div>
);
};
export default FeatureStrategyAccordionBody;

View File

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

View File

@ -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 (
<div className={styles.container}>
<h3 className={styles.header}>Execution plan</h3>
<FeatureStrategyExecution
parameters={parameters}
constraints={constraints}
/>
</div>
);
};
export default FeatureStrategyCreateExecution;

View File

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

View File

@ -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 (
<Fragment key={`${constraint.contextName}-${index}`}>
<FeatureStrategyExecutionConstraint
constraint={constraint}
/>
<FeatureStrategiesSeparator text="AND" />
</Fragment>
);
}
return (
<FeatureStrategyExecutionConstraint
constraint={constraint}
key={`${constraint.contextName}-${index}`}
/>
);
});
};
const renderParameters = () => {
return Object.keys(parameters).map((key, index) => {
switch (key) {
case 'rollout':
case 'Rollout':
return (
<Fragment key={key}>
<p className={styles.text}>
{parameters[key]}% of your user base{' '}
{constraints.length > 0
? 'who match constraints'
: ''}{' '}
are included.
</p>
<PercentageCircle percentage={parameters[key]} />
</Fragment>
);
case 'userIds':
case 'UserIds':
const users = parameters[key]
.split(',')
.filter((userId: string) => userId);
return (
<FeatureStrategyExecutionChips
value={users}
text="user"
/>
);
case 'hostNames':
case 'HostNames':
const hosts = parameters[key]
.split(',')
.filter((hosts: string) => hosts);
return (
<FeatureStrategyExecutionChips
value={hosts}
text={'host'}
/>
);
case 'IPs':
const IPs = parameters[key]
.split(',')
.filter((hosts: string) => hosts);
return (
<FeatureStrategyExecutionChips
value={IPs}
text={'IP'}
/>
);
default:
return null;
}
});
};
return (
<>
<ConditionallyRender
condition={constraints.length > 0}
show={
<div className={styles.constraintsContainer}>
<p>Enabled for match:</p>
{renderConstraints()}
<FeatureStrategiesSeparator text="AND" />
</div>
}
/>
{renderParameters()}
</>
);
};
export default FeatureStrategyExecution;

View File

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

View File

@ -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 (
<div className={styles.container}>
<ConditionallyRender
condition={value.length === 0}
show={<p>No {text}s added yet.</p>}
elseShow={
<div>
<p className={styles.paragraph}>
{value.length}{' '}
{value.length > 1 ? `${text}s` : text} will get
access.
</p>
{value.map((userId: string) => (
<Chip
key={userId}
label={userId}
className={styles.chip}
/>
))}
</div>
}
/>
</div>
);
};
export default FeatureStrategyExecutionChips;

View File

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

View File

@ -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 (
<div className={styles.constraint}>
<p className={styles.constraintName}>{constraint.contextName}</p>
<p className={styles.constraintOperator}>
{translateOperator(constraint.operator)}
</p>
<p className={styles.constraintValues}>
{constraint.values.join(', ')}
</p>
</div>
);
};
export default FeatureStrategyExecutionConstraint;

View File

@ -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 (
<div>
<RolloutSlider
name="Rollout"
value={1 * rollout}
onChange={updateRollout}
/>
<br />
<div>
<Tooltip title="Stickiness defines what parameter should be used to ensure that your users get consistency in features. By default unleash will use the first value present in the context in the order of userId, sessionId and random.">
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
Stickiness
<Info
style={{
fontSize: '1rem',
color: 'gray',
marginLeft: '0.2rem',
}}
/>
</Typography>
</Tooltip>
<Select
id="stickiness-select"
name="stickiness"
label="Stickiness"
options={stickinessOptions}
value={stickiness}
onChange={e =>
onUpdate('stickiness')(e, e.target.value as number)
}
/>
&nbsp;
<br />
<br />
<Tooltip title="GroupId is used to ensure that different toggles will hash differently for the same user. The groupId defaults to feature toggle name, but you can override it to correlate rollout of multiple feature toggles.">
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
GroupId
<Info
style={{
fontSize: '1rem',
color: 'gray',
marginLeft: '0.2rem',
}}
/>
</Typography>
</Tooltip>
<Input
label="groupId"
value={groupId || ''}
onChange={e => onUpdate('groupId')(e, e.target.value)}
/>
</div>
</div>
);
};
export default FlexibleStrategy;

View File

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

View File

@ -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 (
<div key={name}>
<br />
<RolloutSlider
name={name}
onChange={onChangePercentage.bind(this, name)}
value={1 * value}
minLabel="off"
maxLabel="on"
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
} else if (type === 'list') {
let list = [];
if (typeof value === 'string') {
list = value.trim().split(',').filter(Boolean);
}
return (
<div key={name}>
<StrategyInputList
name={name}
list={list}
disabled={!editable}
setConfig={updateParameter}
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
} else if (type === 'number') {
const regex = new RegExp('^\\d+$');
const error = value.length > 0 ? !regex.test(value) : false;
return (
<div key={name} className={styles.generalSection}>
<TextField
error={error !== undefined}
helperText={error && `${name} is not a number!`}
variant="outlined"
size="small"
required={required}
style={{ width: '100%' }}
name={name}
label={name}
onChange={onChangeTextField.bind(this, name)}
value={value}
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
} else if (type === 'boolean') {
return (
<div key={name} style={{ padding: '20px 0' }}>
<Tooltip title={description} placement="right-end">
<FormControlLabel
label={name}
control={
<Switch
name={name}
onChange={handleSwitchChange.bind(
this,
name,
value
)}
checked={value === 'true'}
/>
}
/>
</Tooltip>
</div>
);
} else {
return (
<div key={name} className={styles.generalSection}>
<TextField
rows={1}
placeholder=""
variant="outlined"
size="small"
style={{ width: '100%' }}
required={required}
name={name}
label={name}
onChange={onChangeTextField.bind(this, name)}
value={value}
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
}
}
);
}
return null;
};
export default GeneralStrategy;

View File

@ -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 (
<div className={classes.slider}>
<Typography
id="discrete-slider-always"
variant="subtitle2"
gutterBottom
>
{name}
</Typography>
<br />
<StyledSlider
min={0}
max={100}
value={value}
getAriaValueText={valuetext}
aria-labelledby="discrete-slider-always"
step={1}
marks={marks}
onChange={onChange}
valueLabelDisplay="on"
disabled={disabled}
/>
</div>
);
};
export default RolloutSlider;

View File

@ -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<IStrategyConstraintProps> = ({
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 (
<div className={commonStyles.contentSpacingY}>
<Tooltip
placement="right-start"
title={
<span>
Use context fields to constrain the activation strategy.
</span>
}
>
<Typography variant="subtitle2">
{'Constraints '}
<Info style={{ fontSize: '0.9rem', color: 'gray' }} />
</Typography>
</Tooltip>
<table style={{ margin: 0 }}>
<tbody>
{constraints.map((c, index) => (
<StrategyConstraintInputField
key={`${c.contextName}-${index}`}
id={`${c.contextName}-${index}`}
constraint={c}
contextFields={contextFields}
updateConstraint={updateConstraint(index)}
removeConstraint={removeConstraint(index)}
constraintError={constraintError}
setConstraintError={setConstraintError}
/>
))}
</tbody>
</table>
<small>
<Button
title="Add constraint"
variant="contained"
color="primary"
onClick={onClick}
>
Add constraint
</Button>
</small>
</div>
);
};
export default StrategyConstraints;

View File

@ -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 (
<div>
<Typography variant="subtitle2">List of {name}</Typography>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
margin: '10px 0',
}}
>
{list.map((entryValue, index) => (
<Chip
key={index + entryValue}
label={entryValue}
style={{ marginRight: '3px' }}
onDelete={disabled ? undefined : () => onClose(index)}
title="Remove value"
/>
))}
</div>
<ConditionallyRender
condition={!disabled}
show={
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
name={`input_field`}
variant="outlined"
label="Add items"
value={input}
size="small"
placeholder=""
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<Button
onClick={setValue}
color="secondary"
startIcon={<Add />}
>
Add
</Button>
</div>
}
/>
</div>
);
};
export default StrategyInputList;

View File

@ -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 (
<div>
<StrategyInputList
name="userIds"
list={list}
disabled={!editable}
setConfig={updateParameter}
/>
</div>
);
};
export default UserWithIdStrategy;

View File

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

View File

@ -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<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const { a11yProps, activeTab, setActiveTab } = useTabs(0);
const styles = useStyles();
const renderOverview = () => {
return (
<div style={{ display: 'flex', width: '100%' }}>
<FeatureViewMetaData />
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
{feature?.environments.map(env => {
return (
<FeatureViewEnvironment env={env} key={env.name} />
);
})}
</div>
</div>
);
};
const tabData = [
{ title: 'Overview', component: renderOverview() },
{ title: 'Strategies', component: <FeatureStrategies /> },
];
const renderTabs = () => {
return tabData.map((tab, index) => {
return (
<Tab
key={tab.title}
label={tab.title}
{...a11yProps(index)}
onClick={() => setActiveTab(index)}
className={styles.tabButton}
/>
);
});
};
const renderTabContent = () => {
return tabData.map((tab, index) => {
return (
<TabPanel value={activeTab} index={index}>
{tab.component}
</TabPanel>
);
});
};
return (
<div style={{ display: 'flex', width: '100%' }}>
<FeatureViewMetaData />
{feature.environments.map(env => {
return <FeatureViewEnvironment env={env} key={env.name} />;
})}
</div>
<>
<div className={styles.header}>
<div className={styles.innerContainer}>
<h2 className={styles.featureViewHeader}>{feature.name}</h2>
</div>
<div className={styles.separator} />
<div className={styles.tabContainer}>
<Tabs
value={activeTab}
onChange={(_, tabId) => {
setActiveTab(tabId);
}}
indicatorColor="primary"
textColor="primary"
className={styles.tabNavigation}
>
{renderTabs()}
</Tabs>
</div>
</div>
{renderTabContent()}
</>
);
};

View File

@ -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',

View File

@ -4,7 +4,7 @@ import { useStyles } from './FeatureViewEnvironment.styles';
const FeatureViewEnvironment = ({ env }: any) => {
const styles = useStyles();
return (
<div style={{ width: '100%' }}>
<div style={{ width: '100%', marginBottom: '1rem' }}>
<div className={styles.environmentContainer}>
<Switch value={env.enabled} checked={env.enabled} /> Toggle in{' '}
{env.name} is {env.enabled ? 'enabled' : 'disabled'}

View File

@ -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',

View File

@ -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':

View File

@ -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 (
<div key={name}>
<br />
<StrategyInputPercentage
name={name}
onChange={onChangePercentage.bind(this, name)}
value={1 * value}
minLabel="off"
maxLabel="on"
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'list') {
let list = [];
if (typeof value === 'string') {
list = value
.trim()
.split(',')
.filter(Boolean);
}
return (
<div key={name}>
<StrategyInputList name={name} list={list} disabled={!editable} setConfig={updateParameter} />
{description && <p className={styles.helpText}>{description}</p>}
</div>
);
} else if (type === 'number') {
const regex = new RegExp('^\\d+$');
const error = value.length > 0 ? !regex.test(value) : false;
return (
<div key={name} className={styles.generalSection}>
<TextField
error={error !== undefined}
helperText={error && `${name} is not a number!`}
variant="outlined"
size="small"
required={required}
style={{ width: '100%' }}
name={name}
label={name}
onChange={onChangeTextField.bind(this, name)}
value={value}
/>
{description && <p className={styles.helpText}>{description}</p>}
</div>
);
} else if (type === 'boolean') {
return (
<div key={name} style={{ padding: '20px 0' }}>
<Tooltip title={description} placement="right-end">
<FormControlLabel
label={name}
control={
<Switch
name={name}
onChange={handleSwitchChange.bind(this, name, value)}
checked={value === 'true'}
/>
}
if (type === 'percentage') {
if (
value == null ||
(typeof value === 'string' && value === '')
) {
value = 0;
}
return (
<div key={name}>
<br />
<StrategyInputPercentage
name={name}
onChange={onChangePercentage.bind(this, name)}
value={1 * value}
minLabel="off"
maxLabel="on"
/>
</Tooltip>
</div>
);
} else {
return (
<div key={name} className={styles.generalSection}>
<TextField
rows={1}
placeholder=""
variant="outlined"
size="small"
style={{ width: '100%' }}
required={required}
name={name}
label={name}
onChange={onChangeTextField.bind(this, name)}
value={value}
/>
{description && <p className={styles.helpText}>{description}</p>}
</div>
);
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
} else if (type === 'list') {
let list = [];
if (typeof value === 'string') {
list = value.trim().split(',').filter(Boolean);
}
return (
<div key={name}>
<StrategyInputList
name={name}
list={list}
disabled={!editable}
setConfig={updateParameter}
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
} else if (type === 'number') {
const regex = new RegExp('^\\d+$');
const error = value.length > 0 ? !regex.test(value) : false;
return (
<div key={name} className={styles.generalSection}>
<TextField
error={error !== undefined}
helperText={error && `${name} is not a number!`}
variant="outlined"
size="small"
required={required}
style={{ width: '100%' }}
name={name}
label={name}
onChange={onChangeTextField.bind(this, name)}
value={value}
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
} else if (type === 'boolean') {
return (
<div key={name} style={{ padding: '20px 0' }}>
<Tooltip title={description} placement="right-end">
<FormControlLabel
label={name}
control={
<Switch
name={name}
onChange={handleSwitchChange.bind(
this,
name,
value
)}
checked={value === 'true'}
/>
}
/>
</Tooltip>
</div>
);
} else {
return (
<div key={name} className={styles.generalSection}>
<TextField
rows={1}
placeholder=""
variant="outlined"
size="small"
style={{ width: '100%' }}
required={required}
name={name}
label={name}
onChange={onChangeTextField.bind(this, name)}
value={value}
/>
{description && (
<p className={styles.helpText}>{description}</p>
)}
</div>
);
}
}
});
);
}
return null;
}

View File

@ -462,7 +462,6 @@ exports[`renders correctly with with variants 1`] = `
<div
className="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl MuiInputBase-marginDense MuiOutlinedInput-marginDense"
onClick={[Function]}
size="small"
>
<div
aria-haspopup="listbox"

View File

@ -105,7 +105,6 @@ exports[`renders correctly with one feature 1`] = `
<div
className="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl MuiInputBase-marginDense MuiOutlinedInput-marginDense"
onClick={[Function]}
size="small"
>
<div
aria-haspopup="listbox"

View File

@ -202,6 +202,16 @@ Array [
"title": "Copy",
"type": "protected",
},
Object {
"component": [Function],
"flags": "E",
"layout": "main",
"menu": Object {},
"parent": "/projects",
"path": "/projects/:projectId/features2/:featureId/strategies",
"title": "FeatureView2",
"type": "protected",
},
Object {
"component": [Function],
"flags": "E",

View File

@ -1,7 +1,7 @@
import { baseRoutes, getRoute } from '../routes';
test('returns all baseRoutes', () => {
expect(baseRoutes).toHaveLength(38);
expect(baseRoutes).toHaveLength(39);
expect(baseRoutes).toMatchSnapshot();
});

View File

@ -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',

View File

@ -0,0 +1 @@
export const PRODUCTION = 'production';

View File

@ -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<IStrategyPayload | null>
>;
setActiveEnvironment: React.Dispatch<
React.SetStateAction<IFeatureEnvironment | null>
>;
activeEnvironment: IFeatureEnvironment | null;
expandedSidebar: boolean;
setExpandedSidebar: React.Dispatch<React.SetStateAction<boolean>>;
}
const FeatureStrategiesUIContext =
React.createContext<IFeatureStrategiesUIContext | null>(null);
export default FeatureStrategiesUIContext;

View File

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

View File

@ -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<IFeatureToggle>(FEATURE_CACHE_KEY, fetcher, {
...options,
});
const { data, error } = useSWR<IFeatureToggle>(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,
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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<IToast>({
show: false,
type: 'success',
text: '',
});
const hideToast = () => {
setToastData(prev => ({ ...prev, show: false }));
setToastData((prev: IToast) => ({ ...prev, show: false }));
};
const toast = (
<Toast

View File

@ -1,4 +1,4 @@
import { IStrategy } from './strategy';
import { IFeatureStrategy } from './strategy';
export interface IFeatureToggleListItem {
type: string;
@ -28,7 +28,7 @@ export interface IFeatureToggle {
export interface IFeatureEnvironment {
name: string;
enabled: boolean;
strategies: IStrategy[];
strategies: IFeatureStrategy[];
}
export interface IFeatureVariant {

View File

@ -0,0 +1,4 @@
export interface IFeatureViewParams {
projectId: string;
featureId: string;
}

View File

@ -1,10 +1,18 @@
export interface IStrategy {
constraints: IConstraint[];
export interface IFeatureStrategy {
id: string;
name: string;
constraints: IConstraint[];
parameters: IParameter;
}
export interface IStrategy {
name: string;
displayName: string;
editable: boolean;
deprecated: boolean;
description: string;
}
export interface IConstraint {
values: string[];
operator: string;
@ -17,3 +25,11 @@ export interface IParameter {
stickiness?: string;
[index: string]: any;
}
export interface IStrategyPayload {
name?: string;
constraints: IConstraint[];
parameters: IParameter;
}

View File

@ -102,6 +102,8 @@ const theme = createMuiTheme({
mainHeader: '1.2rem',
subHeader: '1.1rem',
bodySize: '1rem',
smallBody: '0.9rem',
smallerBody: '0.8rem',
},
boxShadows: {
chip: {

View File

@ -1,3 +1,9 @@
import LocationOnIcon from '@material-ui/icons/LocationOn';
import PeopleIcon from '@material-ui/icons/People';
import LanguageIcon from '@material-ui/icons/Language';
import MapIcon from '@material-ui/icons/Map';
import { DonutLarge } from '@material-ui/icons';
const nameMapping = {
applicationHostname: {
name: 'Hosts',
@ -5,7 +11,8 @@ const nameMapping = {
},
default: {
name: 'Standard',
description: 'The standard strategy is strictly on / off for your entire userbase.',
description:
'The standard strategy is strictly on / off for your entire userbase.',
},
flexibleRollout: {
name: 'Gradual rollout',
@ -14,15 +21,18 @@ const nameMapping = {
},
gradualRolloutRandom: {
name: 'Randomized',
description: 'Roll out to a percentage of your userbase and randomly enable the feature on a per request basis',
description:
'Roll out to a percentage of your userbase and randomly enable the feature on a per request basis',
},
gradualRolloutSessionId: {
name: 'Sessions',
description: 'Roll out to a percentage of your userbase and configure stickiness based on sessionId',
description:
'Roll out to a percentage of your userbase and configure stickiness based on sessionId',
},
gradualRolloutUserId: {
name: 'Users',
description: 'Roll out to a percentage of your userbase and configure stickiness based on userId',
description:
'Roll out to a percentage of your userbase and configure stickiness based on userId',
},
remoteAddress: {
name: 'IPs',
@ -34,7 +44,8 @@ const nameMapping = {
},
};
export const getHumanReadbleStrategy = strategyName => 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;
}
};

View File

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