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:
parent
139098fda9
commit
27988e4b30
17
frontend/src/assets/icons/gradual.svg
Normal file
17
frontend/src/assets/icons/gradual.svg
Normal 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 |
@ -31,6 +31,9 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
setStyles(enter);
|
||||
}, 50);
|
||||
} else {
|
||||
if (!leave) {
|
||||
setShow(false);
|
||||
}
|
||||
setStyles(leave);
|
||||
}
|
||||
}
|
||||
|
@ -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%)',
|
@ -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;
|
@ -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;
|
@ -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) => {
|
||||
|
@ -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;
|
@ -0,0 +1,5 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
container: { borderRadius: '10px', boxShadow: 'none', display: 'flex' },
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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 },
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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%',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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' },
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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%',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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)
|
||||
}
|
||||
/>
|
||||
|
||||
<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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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'}
|
||||
|
@ -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',
|
||||
|
@ -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':
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { baseRoutes, getRoute } from '../routes';
|
||||
|
||||
test('returns all baseRoutes', () => {
|
||||
expect(baseRoutes).toHaveLength(38);
|
||||
expect(baseRoutes).toHaveLength(39);
|
||||
expect(baseRoutes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
|
1
frontend/src/constants/environmentTypes.ts
Normal file
1
frontend/src/constants/environmentTypes.ts
Normal file
@ -0,0 +1 @@
|
||||
export const PRODUCTION = 'production';
|
21
frontend/src/contexts/FeatureStrategiesUIContext.ts
Normal file
21
frontend/src/contexts/FeatureStrategiesUIContext.ts
Normal 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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
14
frontend/src/hooks/useTabs.ts
Normal file
14
frontend/src/hooks/useTabs.ts
Normal 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;
|
@ -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
|
||||
|
@ -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 {
|
||||
|
4
frontend/src/interfaces/params.ts
Normal file
4
frontend/src/interfaces/params.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface IFeatureViewParams {
|
||||
projectId: string;
|
||||
featureId: string;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
@ -102,6 +102,8 @@ const theme = createMuiTheme({
|
||||
mainHeader: '1.2rem',
|
||||
subHeader: '1.1rem',
|
||||
bodySize: '1rem',
|
||||
smallBody: '0.9rem',
|
||||
smallerBody: '0.8rem',
|
||||
},
|
||||
boxShadows: {
|
||||
chip: {
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user