1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

Feature toggle page update (#1140)

* feat: add icon to custom strategies

* feat: update feature toggle screen layout

* strategy and constraints separators

* style disabled envirnments

* strategy constraint style

* strategy drag and drop

* feature env emtpy state

* quick add strategy api

* reorder strategies api integration

* feature strategy header title

* openapi update

* style small chip component

* fix comments after review

* fix issues with strategy constraint operators

* Revert "openapi update"

This reverts commit 27e7651ebae26f61ca76ec910e1f209bae7f2955.

* fix tooltip ref
This commit is contained in:
Tymoteusz Czech 2022-07-27 12:00:15 +02:00 committed by GitHub
parent d1d23d1b4c
commit c70b38a62a
51 changed files with 897 additions and 500 deletions

View File

@ -33,7 +33,7 @@ export const useStyles = makeStyles()(theme => ({
}, },
headerMetaInfo: { headerMetaInfo: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'stretch',
[theme.breakpoints.down(710)]: { flexDirection: 'column' }, [theme.breakpoints.down(710)]: { flexDirection: 'column' },
}, },
headerContainer: { headerContainer: {
@ -48,12 +48,11 @@ export const useStyles = makeStyles()(theme => ({
}, },
headerValuesContainerWrapper: { headerValuesContainerWrapper: {
display: 'flex', display: 'flex',
flexDirection: 'row', alignItems: 'stretch',
justifyContent: 'center',
}, },
headerValuesContainer: { headerValuesContainer: {
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'stretch',
}, },
headerValues: { headerValues: {
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
@ -106,7 +105,10 @@ export const useStyles = makeStyles()(theme => ({
}, },
display: 'inline-flex', display: 'inline-flex',
}, },
headerSelect: { marginRight: '1rem', width: '200px' }, headerSelect: {
marginRight: '1rem',
width: '200px',
},
chip: { chip: {
margin: '0 0.5rem 0.5rem 0', margin: '0 0.5rem 0.5rem 0',
}, },
@ -121,7 +123,7 @@ export const useStyles = makeStyles()(theme => ({
}, },
}, },
accordionDetails: { accordionDetails: {
borderTop: `1px solid ${theme.palette.grey[300]}`, borderTop: `1px dashed ${theme.palette.grey[300]}`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}, },
@ -132,33 +134,16 @@ export const useStyles = makeStyles()(theme => ({
}, },
summary: { summary: {
border: 'none', border: 'none',
padding: '0.25rem 1rem', padding: theme.spacing(0.5, 3),
'&:hover .valuesExpandLabel': { '&:hover .valuesExpandLabel': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
}, },
settingsParagraph: {
display: 'flex',
alignItems: 'center',
padding: '0.5rem 0',
},
settingsIcon: { settingsIcon: {
height: '32.5px', height: '32.5px',
width: '32.5px', width: '32.5px',
marginRight: '0.5rem', marginRight: '0.5rem',
fill: theme.palette.inactiveIcon, fill: theme.palette.inactiveIcon,
}, },
singleValueView: {
display: 'flex',
alignItems: 'center',
[theme.breakpoints.down(600)]: { flexDirection: 'column' },
},
singleValueText: {
marginRight: '0.75rem',
[theme.breakpoints.down(600)]: {
marginBottom: '0.75rem',
marginRight: 0,
},
},
form: { padding: 0, margin: 0, width: '100%' }, form: { padding: 0, margin: 0, width: '100%' },
})); }));

View File

@ -1,4 +1,4 @@
import { Tooltip } from '@mui/material'; import { Tooltip, Box } from '@mui/material';
import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg'; import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg';
import { ReactComponent as CaseSensitiveOff } from 'assets/icons/24_Text format off.svg'; import { ReactComponent as CaseSensitiveOff } from 'assets/icons/24_Text format off.svg';
import React from 'react'; import React from 'react';
@ -17,30 +17,35 @@ interface CaseSensitiveButtonProps {
export const CaseSensitiveButton = ({ export const CaseSensitiveButton = ({
localConstraint, localConstraint,
setCaseInsensitive, setCaseInsensitive,
}: CaseSensitiveButtonProps) => { }: CaseSensitiveButtonProps) => (
return ( <Tooltip
<ConditionallyRender title={
condition={Boolean(localConstraint.caseInsensitive)} Boolean(localConstraint.caseInsensitive)
show={ ? 'Make it case sensitive'
<Tooltip title="Make it case sensitive" arrow> : 'Make it case insensitive'
}
arrow
>
<Box sx={{ display: 'flex', alignItems: 'stretch' }}>
<ConditionallyRender
condition={Boolean(localConstraint.caseInsensitive)}
show={
<StyledToggleButtonOff <StyledToggleButtonOff
onClick={setCaseInsensitive} onClick={setCaseInsensitive}
disableRipple disableRipple
> >
<CaseSensitiveOff /> <CaseSensitiveOff />
</StyledToggleButtonOff> </StyledToggleButtonOff>
</Tooltip> }
} elseShow={
elseShow={
<Tooltip title="Make it case insensitive" arrow>
<StyledToggleButtonOn <StyledToggleButtonOn
onClick={setCaseInsensitive} onClick={setCaseInsensitive}
disableRipple disableRipple
> >
<CaseSensitive /> <CaseSensitive />
</StyledToggleButtonOn> </StyledToggleButtonOn>
</Tooltip> }
} />
/> </Box>
); </Tooltip>
}; );

View File

@ -1,8 +1,7 @@
import { Tooltip } from '@mui/material'; import { Box, Tooltip } from '@mui/material';
import { ReactComponent as NegatedIcon } from '../../../../../../assets/icons/24_Negator.svg'; import { ReactComponent as NegatedIcon } from 'assets/icons/24_Negator.svg';
import { ReactComponent as NegatedIconOff } from '../../../../../../assets/icons/24_Negator off.svg'; import { ReactComponent as NegatedIconOff } from 'assets/icons/24_Negator off.svg';
import React from 'react'; import { IConstraint } from 'interfaces/strategy';
import { IConstraint } from '../../../../../../interfaces/strategy';
import { import {
StyledToggleButtonOff, StyledToggleButtonOff,
StyledToggleButtonOn, StyledToggleButtonOn,
@ -17,30 +16,35 @@ interface InvertedOperatorButtonProps {
export const InvertedOperatorButton = ({ export const InvertedOperatorButton = ({
localConstraint, localConstraint,
setInvertedOperator, setInvertedOperator,
}: InvertedOperatorButtonProps) => { }: InvertedOperatorButtonProps) => (
return ( <Tooltip
<ConditionallyRender title={
condition={Boolean(localConstraint.inverted)} Boolean(localConstraint.inverted)
show={ ? 'Remove negation'
<Tooltip title="Remove negation" arrow> : 'Negate operator'
}
arrow
>
<Box sx={{ display: 'flex', alignItems: 'stretch' }}>
<ConditionallyRender
condition={Boolean(localConstraint.inverted)}
show={
<StyledToggleButtonOn <StyledToggleButtonOn
onClick={setInvertedOperator} onClick={setInvertedOperator}
disableRipple disableRipple
> >
<NegatedIcon /> <NegatedIcon />
</StyledToggleButtonOn> </StyledToggleButtonOn>
</Tooltip> }
} elseShow={
elseShow={
<Tooltip title="Negate operator" arrow>
<StyledToggleButtonOff <StyledToggleButtonOff
onClick={setInvertedOperator} onClick={setInvertedOperator}
disableRipple disableRipple
> >
<NegatedIconOff /> <NegatedIconOff />
</StyledToggleButtonOff> </StyledToggleButtonOff>
</Tooltip> }
} />
/> </Box>
); </Tooltip>
}; );

View File

@ -3,8 +3,8 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
container: { container: {
width: '100%', width: '100%',
display: 'grid', display: 'flex',
gap: '1rem', flexDirection: 'column',
}, },
help: { help: {
fill: theme.palette.grey[600], fill: theme.palette.grey[600],

View File

@ -1,5 +1,7 @@
import React, { forwardRef, Fragment, useImperativeHandle } from 'react';
import { Button, Tooltip } from '@mui/material';
import { Help } from '@mui/icons-material';
import { IConstraint } from 'interfaces/strategy'; import { IConstraint } from 'interfaces/strategy';
import React, { forwardRef, useImperativeHandle } from 'react';
import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion'; import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion';
import produce from 'immer'; import produce from 'immer';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
@ -8,8 +10,7 @@ import { objectId } from 'utils/objectId';
import { useStyles } from './ConstraintAccordionList.styles'; import { useStyles } from './ConstraintAccordionList.styles';
import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint'; import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Button, Tooltip } from '@mui/material'; import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { Help } from '@mui/icons-material';
interface IConstraintAccordionListProps { interface IConstraintAccordionListProps {
constraints: IConstraint[]; constraints: IConstraint[];
@ -129,16 +130,22 @@ export const ConstraintAccordionList = forwardRef<
} }
/> />
{constraints.map((constraint, index) => ( {constraints.map((constraint, index) => (
<ConstraintAccordion <Fragment key={`${constraint.contextName}-${index}`}>
key={objectId(constraint)} <ConditionallyRender
constraint={constraint} condition={index > 0}
onEdit={onEdit && onEdit.bind(null, constraint)} show={<StrategySeparator text="AND" />}
onCancel={onCancel.bind(null, index)} />
onDelete={onRemove && onRemove.bind(null, index)} <ConstraintAccordion
onSave={onSave && onSave.bind(null, index)} key={objectId(constraint)}
editing={Boolean(state.get(constraint)?.editing)} constraint={constraint}
compact onEdit={onEdit && onEdit.bind(null, constraint)}
/> onCancel={onCancel.bind(null, index)}
onDelete={onRemove && onRemove.bind(null, index)}
onSave={onSave && onSave.bind(null, index)}
editing={Boolean(state.get(constraint)?.editing)}
compact
/>
</Fragment>
))} ))}
</div> </div>
); );

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Accordion, AccordionSummary, AccordionDetails } from '@mui/material'; import { Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { IConstraint } from 'interfaces/strategy'; import { IConstraint } from 'interfaces/strategy';
import { ConstraintAccordionViewBody } from './ConstraintAccordionViewBody/ConstraintAccordionViewBody'; import { ConstraintAccordionViewBody } from './ConstraintAccordionViewBody/ConstraintAccordionViewBody';
import { ConstraintAccordionViewHeader } from './ConstraintAccordionViewHeader/ConstraintAccordionViewHeader'; import { ConstraintAccordionViewHeader } from './ConstraintAccordionViewHeader/ConstraintAccordionViewHeader';
import { oneOf } from 'utils/oneOf'; import { oneOf } from 'utils/oneOf';
@ -9,9 +9,8 @@ import {
numOperators, numOperators,
semVerOperators, semVerOperators,
} from 'constants/operators'; } from 'constants/operators';
import { useStyles } from '../ConstraintAccordion.styles'; import { useStyles } from '../ConstraintAccordion.styles';
import { useState } from 'react';
interface IConstraintAccordionViewProps { interface IConstraintAccordionViewProps {
constraint: IConstraint; constraint: IConstraint;
onDelete?: () => void; onDelete?: () => void;
@ -46,7 +45,7 @@ export const ConstraintAccordionView = ({
sx={{ cursor: expandable ? 'pointer' : 'default' }} sx={{ cursor: expandable ? 'pointer' : 'default' }}
> >
<AccordionSummary <AccordionSummary
className={styles.summary} classes={{ root: styles.summary }}
expandIcon={null} expandIcon={null}
onClick={handleClick} onClick={handleClick}
> >

View File

@ -0,0 +1,27 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
chip: {
margin: '0 0.5rem 0.5rem 0',
},
chipValue: {
whiteSpace: 'pre',
},
singleValueView: {
display: 'flex',
alignItems: 'center',
[theme.breakpoints.down(600)]: { flexDirection: 'column' },
},
singleValueText: {
marginRight: '0.75rem',
[theme.breakpoints.down(600)]: {
marginBottom: '0.75rem',
marginRight: 0,
},
},
settingsParagraph: {
display: 'flex',
alignItems: 'center',
padding: '0.5rem 0',
},
}));

View File

@ -1,15 +1,14 @@
import { Chip } from '@mui/material';
import { ImportExportOutlined, TextFormatOutlined } from '@mui/icons-material'; import { ImportExportOutlined, TextFormatOutlined } from '@mui/icons-material';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useState } from 'react';
import { stringOperators } from 'constants/operators'; import { stringOperators } from 'constants/operators';
import { IConstraint } from 'interfaces/strategy'; import { IConstraint } from 'interfaces/strategy';
import { oneOf } from 'utils/oneOf'; import { oneOf } from 'utils/oneOf';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import { formatConstraintValue } from 'utils/formatConstraintValue'; import { formatConstraintValue } from 'utils/formatConstraintValue';
import { useLocationSettings } from 'hooks/useLocationSettings'; import { useLocationSettings } from 'hooks/useLocationSettings';
import { useStyles as useAccordionStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
import { useStyles } from './ConstraintAccordionViewBody.style';
import { MultipleValues } from './MultipleValues/MultipleValues';
import { SingleValue } from './SingleValue/SingleValue';
interface IConstraintAccordionViewBodyProps { interface IConstraintAccordionViewBodyProps {
constraint: IConstraint; constraint: IConstraint;
@ -18,7 +17,8 @@ interface IConstraintAccordionViewBodyProps {
export const ConstraintAccordionViewBody = ({ export const ConstraintAccordionViewBody = ({
constraint, constraint,
}: IConstraintAccordionViewBodyProps) => { }: IConstraintAccordionViewBodyProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useAccordionStyles();
const { classes } = useStyles();
const { locationSettings } = useLocationSettings(); const { locationSettings } = useLocationSettings();
return ( return (
@ -29,7 +29,7 @@ export const ConstraintAccordionViewBody = ({
Boolean(constraint.caseInsensitive) Boolean(constraint.caseInsensitive)
} }
show={ show={
<p className={styles.settingsParagraph}> <p className={classes.settingsParagraph}>
<TextFormatOutlined className={styles.settingsIcon} />{' '} <TextFormatOutlined className={styles.settingsIcon} />{' '}
Case insensitive setting is active Case insensitive setting is active
</p> </p>
@ -39,7 +39,7 @@ export const ConstraintAccordionViewBody = ({
<ConditionallyRender <ConditionallyRender
condition={Boolean(constraint.inverted)} condition={Boolean(constraint.inverted)}
show={ show={
<p className={styles.settingsParagraph}> <p className={classes.settingsParagraph}>
<ImportExportOutlined className={styles.settingsIcon} />{' '} <ImportExportOutlined className={styles.settingsIcon} />{' '}
Operator is negated Operator is negated
</p> </p>
@ -56,70 +56,3 @@ export const ConstraintAccordionViewBody = ({
</div> </div>
); );
}; };
interface ISingleValueProps {
value: string | undefined;
operator: string;
}
const SingleValue = ({ value, operator }: ISingleValueProps) => {
const { classes: styles } = useStyles();
if (!value) return null;
return (
<div className={styles.singleValueView}>
<p className={styles.singleValueText}>Value must be {operator}</p>{' '}
<Chip
label={
<StringTruncator
maxWidth="400"
text={value}
maxLength={50}
/>
}
className={styles.chip}
/>
</div>
);
};
interface IMultipleValuesProps {
values: string[] | undefined;
}
const MultipleValues = ({ values }: IMultipleValuesProps) => {
const [filter, setFilter] = useState('');
const { classes: styles } = useStyles();
if (!values || values.length === 0) return null;
return (
<>
<ConditionallyRender
condition={values.length > 20}
show={
<ConstraintValueSearch
filter={filter}
setFilter={setFilter}
/>
}
/>
{values
.filter(value => value.includes(filter))
.map((value, index) => (
<Chip
key={`${value}-${index}`}
label={
<StringTruncator
maxWidth="400"
text={value}
maxLength={50}
className={styles.chipValue}
/>
}
className={styles.chip}
/>
))}
</>
);
};

View File

@ -0,0 +1,47 @@
import { useState } from 'react';
import { Chip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ConstraintValueSearch } from '../../../ConstraintValueSearch/ConstraintValueSearch';
import { useStyles } from '../ConstraintAccordionViewBody.style';
interface IMultipleValuesProps {
values: string[] | undefined;
}
export const MultipleValues = ({ values }: IMultipleValuesProps) => {
const [filter, setFilter] = useState('');
const { classes: styles } = useStyles();
if (!values || values.length === 0) return null;
return (
<>
<ConditionallyRender
condition={values.length > 20}
show={
<ConstraintValueSearch
filter={filter}
setFilter={setFilter}
/>
}
/>
{values
.filter(value => value.includes(filter))
.map((value, index) => (
<Chip
key={`${value}-${index}`}
label={
<StringTruncator
maxWidth="400"
text={value}
maxLength={50}
className={styles.chipValue}
/>
}
className={styles.chip}
/>
))}
</>
);
};

View File

@ -0,0 +1,29 @@
import { Chip } from '@mui/material';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useStyles } from '../ConstraintAccordionViewBody.style';
interface ISingleValueProps {
value: string | undefined;
operator: string;
}
export const SingleValue = ({ value, operator }: ISingleValueProps) => {
const { classes: styles } = useStyles();
if (!value) return null;
return (
<div className={styles.singleValueView}>
<p className={styles.singleValueText}>Value must be {operator}</p>{' '}
<Chip
label={
<StringTruncator
maxWidth="400"
text={value}
maxLength={50}
/>
}
className={styles.chip}
/>
</div>
);
};

View File

@ -1,10 +1,8 @@
import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon'; import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon';
import { IConstraint } from 'interfaces/strategy'; import { IConstraint } from 'interfaces/strategy';
import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
import React from 'react';
import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo'; import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo';
import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions'; import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions';
import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
interface IConstraintAccordionViewHeaderProps { interface IConstraintAccordionViewHeaderProps {
constraint: IConstraint; constraint: IConstraint;

View File

@ -15,6 +15,8 @@ const StyledHeaderText = styled('span')(({ theme }) => ({
maxWidth: '100px', maxWidth: '100px',
minWidth: '100px', minWidth: '100px',
marginRight: '10px', marginRight: '10px',
marginTop: 'auto',
marginBottom: 'auto',
wordBreak: 'break-word', wordBreak: 'break-word',
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
[theme.breakpoints.down(710)]: { [theme.breakpoints.down(710)]: {

View File

@ -1,9 +1,8 @@
import { IConstraint } from '../../../../../../interfaces/strategy'; import { IConstraint } from '../../../../../../interfaces/strategy';
import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
import { Tooltip } from '@mui/material'; import { Tooltip, Box } from '@mui/material';
import { ReactComponent as NegatedIcon } from '../../../../../../assets/icons/24_Negator.svg'; import { ReactComponent as NegatedIcon } from '../../../../../../assets/icons/24_Negator.svg';
import { ConstraintOperator } from '../../../ConstraintOperator/ConstraintOperator'; import { ConstraintOperator } from '../../../ConstraintOperator/ConstraintOperator';
import React from 'react';
import { useStyles } from '../../../ConstraintAccordion.styles'; import { useStyles } from '../../../ConstraintAccordion.styles';
import { StyledIconWrapper } from '../StyledIconWrapper/StyledIconWrapper'; import { StyledIconWrapper } from '../StyledIconWrapper/StyledIconWrapper';
@ -22,14 +21,19 @@ export const ConstraintViewHeaderOperator = ({
condition={Boolean(constraint.inverted)} condition={Boolean(constraint.inverted)}
show={ show={
<Tooltip title={'Operator is negated'} arrow> <Tooltip title={'Operator is negated'} arrow>
<StyledIconWrapper marginright={'0'}> <Box sx={{ display: 'flex' }}>
<NegatedIcon /> <StyledIconWrapper isPrefix>
</StyledIconWrapper> <NegatedIcon />
</StyledIconWrapper>
</Box>
</Tooltip> </Tooltip>
} }
/> />
<div className={styles.headerConstraintContainer}> <div className={styles.headerConstraintContainer}>
<ConstraintOperator constraint={constraint} /> <ConstraintOperator
constraint={constraint}
hasPrefix={Boolean(constraint.inverted)}
/>
</div> </div>
</div> </div>
); );

View File

@ -17,6 +17,7 @@ const StyledValuesSpan = styled('span')(({ theme }) => ({
overflow: 'hidden', overflow: 'hidden',
wordBreak: 'break-word', wordBreak: 'break-word',
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
margin: 'auto 0',
[theme.breakpoints.down(710)]: { [theme.breakpoints.down(710)]: {
margin: theme.spacing(1, 0), margin: theme.spacing(1, 0),
textAlign: 'center', textAlign: 'center',
@ -90,8 +91,7 @@ export const ConstraintAccordionViewHeaderMultipleValues = ({
)} )}
> >
{!expanded {!expanded
? `View all ( ? `View all (${constraint?.values?.length})`
${constraint?.values?.length})`
: 'View less'} : 'View less'}
</p> </p>
} }

View File

@ -4,13 +4,13 @@ import { stringOperators } from '../../../../../../constants/operators';
import { Chip, styled, Tooltip } from '@mui/material'; import { Chip, styled, Tooltip } from '@mui/material';
import { ReactComponent as CaseSensitive } from '../../../../../../assets/icons/24_Text format.svg'; import { ReactComponent as CaseSensitive } from '../../../../../../assets/icons/24_Text format.svg';
import { formatConstraintValue } from '../../../../../../utils/formatConstraintValue'; import { formatConstraintValue } from '../../../../../../utils/formatConstraintValue';
import React from 'react';
import { useStyles } from '../../../ConstraintAccordion.styles'; import { useStyles } from '../../../ConstraintAccordion.styles';
import { StyledIconWrapper } from '../StyledIconWrapper/StyledIconWrapper'; import { StyledIconWrapper } from '../StyledIconWrapper/StyledIconWrapper';
import { IConstraint } from '../../../../../../interfaces/strategy'; import { IConstraint } from '../../../../../../interfaces/strategy';
import { useLocationSettings } from '../../../../../../hooks/useLocationSettings'; import { useLocationSettings } from '../../../../../../hooks/useLocationSettings';
const StyledSingleValueChip = styled(Chip)(({ theme }) => ({ const StyledSingleValueChip = styled(Chip)(({ theme }) => ({
margin: 'auto 0',
[theme.breakpoints.down(710)]: { [theme.breakpoints.down(710)]: {
margin: theme.spacing(1, 0), margin: theme.spacing(1, 0),
}, },

View File

@ -1,16 +1,34 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FC, forwardRef } from 'react';
export const StyledIconWrapper = styled('div')<{ export const StyledIconWrapperBase = styled('div')<{
marginright?: string; prefix?: boolean;
}>(({ theme, marginright }) => ({ }>(({ theme }) => ({
backgroundColor: theme.palette.grey[200], backgroundColor: theme.palette.grey[200],
width: 28, width: 28,
height: 48, display: 'flex',
display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '10px 0', alignSelf: 'stretch',
color: theme.palette.primary.main, color: theme.palette.primary.main,
marginRight: marginright ? marginright : '1rem', marginRight: '1rem',
marginTop: 'auto', borderRadius: theme.shape.borderRadius,
marginBottom: 'auto',
})); }));
const StyledPrefixIconWrapper = styled(StyledIconWrapperBase)(() => ({
marginRight: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}));
export const StyledIconWrapper = forwardRef<
HTMLDivElement,
{ isPrefix?: boolean; children?: React.ReactNode }
>(({ isPrefix, ...props }, ref) => (
<ConditionallyRender
condition={Boolean(isPrefix)}
show={() => <StyledPrefixIconWrapper ref={ref} {...props} />}
elseShow={() => <StyledIconWrapperBase ref={ref} {...props} />}
/>
));

View File

@ -2,7 +2,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
container: { container: {
padding: '0.5rem 0.75rem', padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.grey[200], backgroundColor: theme.palette.grey[200],
lineHeight: 1.25, lineHeight: 1.25,

View File

@ -5,10 +5,12 @@ import React from 'react';
interface IConstraintOperatorProps { interface IConstraintOperatorProps {
constraint: IConstraint; constraint: IConstraint;
hasPrefix?: boolean;
} }
export const ConstraintOperator = ({ export const ConstraintOperator = ({
constraint, constraint,
hasPrefix,
}: IConstraintOperatorProps) => { }: IConstraintOperatorProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
@ -16,7 +18,13 @@ export const ConstraintOperator = ({
const operatorText = formatOperatorDescription(constraint.operator); const operatorText = formatOperatorDescription(constraint.operator);
return ( return (
<div className={styles.container}> <div
className={styles.container}
style={{
borderTopLeftRadius: hasPrefix ? 0 : undefined,
borderBottomLeftRadius: hasPrefix ? 0 : undefined,
}}
>
<div className={styles.name}>{operatorName}</div> <div className={styles.name}>{operatorName}</div>
<div className={styles.text}>{operatorText}</div> <div className={styles.text}>{operatorText}</div>
</div> </div>

View File

@ -1,24 +1,45 @@
import { useTheme } from '@mui/material'; import { styled } from '@mui/material';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
interface IStrategySeparatorProps { interface IStrategySeparatorProps {
text: string; text: 'AND' | 'OR';
} }
export const StrategySeparator = ({ text }: IStrategySeparatorProps) => { const StyledContainer = styled('div')(({ theme }) => ({
const theme = useTheme(); height: theme.spacing(2),
position: 'relative',
width: '100%',
}));
return ( const StyledContent = styled('div')(({ theme }) => ({
<div padding: theme.spacing(0.75, 1.5),
style={{ color: theme.palette.text.primary,
color: theme.palette.primary.main, fontSize: theme.fontSizes.smallerBody,
padding: '0.1rem 0.25rem', backgroundColor: theme.palette.secondaryContainer,
border: `1px solid ${theme.palette.primary.main}`, borderRadius: theme.shape.borderRadius,
borderRadius: '0.25rem', position: 'absolute',
fontSize: theme.fontSizes.smallerBody, zIndex: theme.zIndex.fab,
backgroundColor: '#fff', top: '50%',
}} left: theme.spacing(3),
> transform: 'translateY(-50%)',
{text} }));
</div>
); const StyledCenteredContent = styled(StyledContent)(({ theme }) => ({
}; top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: theme.palette.secondary.light,
border: `1px solid ${theme.palette.primary.border}`,
}));
export const StrategySeparator = ({ text }: IStrategySeparatorProps) => (
<StyledContainer>
<ConditionallyRender
condition={text === 'AND'}
show={() => <StyledContent>{text}</StyledContent>}
elseShow={() => (
<StyledCenteredContent>{text}</StyledCenteredContent>
)}
/>
</StyledContainer>
);

View File

@ -2,7 +2,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
emptyStateListItem: { emptyStateListItem: {
border: `2px dashed ${theme.palette.grey[100]}`, border: `2px dashed ${theme.palette.neutral.light}`,
padding: '0.8rem', padding: '0.8rem',
textAlign: 'center', textAlign: 'center',
display: 'flex', display: 'flex',

View File

@ -1,14 +1,22 @@
import { makeStyles } from 'tss-react/mui'; import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
noItemsParagraph: { container: {
margin: '1rem 0', display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}, },
link: { title: {
display: 'block', fontSize: theme.fontSizes.bodySize,
margin: '1rem 0 0 0', textAlign: 'center',
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
}, },
envName: { description: {
fontWeight: 'bold', color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
textAlign: 'center',
marginBottom: theme.spacing(3),
}, },
})); }));

View File

@ -1,7 +1,13 @@
import NoItems from 'component/common/NoItems/NoItems'; import { Link } from 'react-router-dom';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import { Box } from '@mui/material';
import { useStyles } from './FeatureStrategyEmpty.styles'; import { SectionSeparator } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import useToast from 'hooks/useToast';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { FeatureStrategyMenu } from '../FeatureStrategyMenu/FeatureStrategyMenu'; import { FeatureStrategyMenu } from '../FeatureStrategyMenu/FeatureStrategyMenu';
import { PresetCard } from './PresetCard/PresetCard';
import { useStyles } from './FeatureStrategyEmpty.styles';
import { formatUnknownError } from 'utils/formatUnknownError';
interface IFeatureStrategyEmptyProps { interface IFeatureStrategyEmptyProps {
projectId: string; projectId: string;
@ -15,30 +21,58 @@ export const FeatureStrategyEmpty = ({
environmentId, environmentId,
}: IFeatureStrategyEmptyProps) => { }: IFeatureStrategyEmptyProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { addStrategyToFeature } = useFeatureStrategyApi();
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(projectId, featureId);
const onAfterAddStrategy = () => {
refetchFeature();
setToastData({
title: 'Strategy created',
text: 'Successfully created strategy',
type: 'success',
});
};
const onAddSimpleStrategy = async () => {
try {
await addStrategyToFeature(projectId, featureId, environmentId, {
name: 'default',
parameters: {},
constraints: [],
});
onAfterAddStrategy();
} catch (error) {
setToastApiError(formatUnknownError(error));
}
};
const onAddGradualRolloutStrategy = async () => {
try {
await addStrategyToFeature(projectId, featureId, environmentId, {
name: 'flexibleRollout',
parameters: {
rollout: '50',
stickiness: 'default',
},
constraints: [],
});
onAfterAddStrategy();
} catch (error) {
setToastApiError(formatUnknownError(error));
}
};
return ( return (
<NoItems> <div className={styles.container}>
<p className={styles.noItemsParagraph}> <div className={styles.title}>
No strategies added in the{' '} You have not defined any strategies yet.
<StringTruncator </div>
text={environmentId} <p className={styles.description}>
maxWidth={'130'}
maxLength={15}
className={styles.envName}
/>{' '}
environment
</p>
<p className={styles.noItemsParagraph}>
Strategies added in this environment will only be executed if Strategies added in this environment will only be executed if
the SDK is using an API key configured for this environment. the SDK is using an{' '}
<a <Link to="/admin/api">API key configured</Link> for this
className={styles.link} environment.
href="https://docs.getunleash.io/user_guide/environments"
target="_blank"
rel="noreferrer"
>
Read more here
</a>
</p> </p>
<FeatureStrategyMenu <FeatureStrategyMenu
label="Add your first strategy" label="Add your first strategy"
@ -46,6 +80,31 @@ export const FeatureStrategyEmpty = ({
featureId={featureId} featureId={featureId}
environmentId={environmentId} environmentId={environmentId}
/> />
</NoItems> <Box sx={{ width: '100%', mt: 3 }}>
<SectionSeparator>Or use a strategy template</SectionSeparator>
</Box>
<Box
sx={{
display: 'grid',
width: '100%',
gap: 2,
gridTemplateColumns: '1fr 1fr',
}}
>
<PresetCard
title="Standard strategy"
onClick={onAddSimpleStrategy}
>
The standard strategy is strictly on/off for your entire
userbase.
</PresetCard>
<PresetCard
title="Gradual rollout"
onClick={onAddGradualRolloutStrategy}
>
Roll out to a percentage of your userbase.
</PresetCard>
</Box>
</div>
); );
}; };

View File

@ -0,0 +1,35 @@
import { Button, Card, CardContent, Typography } from '@mui/material';
import { FC } from 'react';
interface IPresetCardProps {
title: string;
onClick: () => void;
}
export const PresetCard: FC<IPresetCardProps> = ({
title,
children,
onClick,
}) => (
<Card variant="outlined" sx={{ display: 'flex', flexDirection: 'column' }}>
<CardContent
sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}
>
<Typography variant="body1" fontWeight="medium" sx={{ mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" component="p">
{children}
</Typography>
<Button
variant="outlined"
size="small"
sx={{ ml: 'auto', mt: 'auto' }}
onClick={onClick}
>
Use template
</Button>
</CardContent>
</Card>
);

View File

@ -16,7 +16,9 @@ export const FeatureStrategyIcon = ({
return ( return (
<StyledIcon> <StyledIcon>
<Tooltip title={formatStrategyName(strategyName)} arrow> <Tooltip title={formatStrategyName(strategyName)} arrow>
<Icon /> <>
<Icon />
</>
</Tooltip> </Tooltip>
</StyledIcon> </StyledIcon>
); );

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import PermissionButton, { import PermissionButton, {
IPermissionButtonProps, IPermissionButtonProps,
} from 'component/common/PermissionButton/PermissionButton'; } from 'component/common/PermissionButton/PermissionButton';
import React, { useState } from 'react';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { Popover } from '@mui/material'; import { Popover } from '@mui/material';
import { FeatureStrategyMenuCards } from './FeatureStrategyMenuCards/FeatureStrategyMenuCards'; import { FeatureStrategyMenuCards } from './FeatureStrategyMenuCards/FeatureStrategyMenuCards';

View File

@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
item: { item: {
padding: theme.spacing(2), padding: theme.spacing(2),
background: theme.palette.grey[100], background: theme.palette.secondaryContainer,
borderRadius: theme.spacing(2), borderRadius: theme.spacing(2),
textAlign: 'center', textAlign: 'center',
[theme.breakpoints.up('md')]: { [theme.breakpoints.up('md')]: {

View File

@ -0,0 +1,14 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
accordionBodyInnerContainer: {
[theme.breakpoints.down(400)]: {
padding: '0.5rem',
},
},
accordionBody: {
width: '100%',
position: 'relative',
paddingBottom: '1rem',
},
}));

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { Alert } from '@mui/material';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategyDraggableItem } from './StrategyDraggableItem/StrategyDraggableItem';
import { IFeatureEnvironment } from 'interfaces/featureToggle';
import { FeatureStrategyEmpty } from 'component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useStyles } from './EnvironmentAccordionBody.styles';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
interface IEnvironmentAccordionBodyProps {
isDisabled: boolean;
featureEnvironment?: IFeatureEnvironment;
}
const EnvironmentAccordionBody = ({
featureEnvironment,
isDisabled,
}: IEnvironmentAccordionBodyProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { setStrategiesSortOrder } = useFeatureStrategyApi();
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(projectId, featureId);
const [strategies, setStrategies] = useState(
featureEnvironment?.strategies || []
);
const { classes: styles } = useStyles();
useEffect(() => {
// Use state to enable drag and drop, but switch to API output when it arrives
setStrategies(featureEnvironment?.strategies || []);
}, [featureEnvironment?.strategies]);
if (!featureEnvironment) {
return null;
}
const onDragAndDrop = async (
from: number,
to: number,
dropped?: boolean
) => {
if (from !== to && dropped) {
const newStrategies = [...strategies];
const movedStrategy = newStrategies.splice(from, 1)[0];
newStrategies.splice(to, 0, movedStrategy);
setStrategies(newStrategies);
try {
await setStrategiesSortOrder(
projectId,
featureId,
featureEnvironment.name,
[...newStrategies].map((strategy, sortOrder) => ({
id: strategy.id,
sortOrder,
}))
);
refetchFeature();
setToastData({
title: 'Order of strategies updated',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
return (
<div className={styles.accordionBody}>
<div className={styles.accordionBodyInnerContainer}>
<ConditionallyRender
condition={strategies.length > 0 && isDisabled}
show={() => (
<Alert severity="warning" sx={{ mb: 2 }}>
This environment is disabled, which means that none
of your strategies are executing.
</Alert>
)}
/>
<ConditionallyRender
condition={strategies.length > 0}
show={
<>
{strategies.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
onDragAndDrop={onDragAndDrop}
strategy={strategy}
index={index}
environmentName={featureEnvironment.name}
/>
))}
</>
}
elseShow={
<FeatureStrategyEmpty
projectId={projectId}
featureId={featureId}
environmentId={featureEnvironment.name}
/>
}
/>
</div>
</div>
);
};
export default EnvironmentAccordionBody;

View File

@ -0,0 +1,35 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { MoveListItem, useDragItem } from 'hooks/useDragItem';
import { IFeatureStrategy } from 'interfaces/strategy';
import { StrategyItem } from './StrategyItem/StrategyItem';
interface IStrategyDraggableItemProps {
strategy: IFeatureStrategy;
environmentName: string;
index: number;
onDragAndDrop: MoveListItem;
}
export const StrategyDraggableItem = ({
strategy,
index,
environmentName,
onDragAndDrop,
}: IStrategyDraggableItemProps) => {
const ref = useDragItem(index, onDragAndDrop);
return (
<div key={strategy.id} ref={ref}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text="OR" />}
/>
<StrategyItem
strategy={strategy}
environmentId={environmentName}
isDraggable
/>
</div>
);
};

View File

@ -1,7 +1,12 @@
import { makeStyles } from 'tss-react/mui'; import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
container: { textAlign: 'center' }, container: {
width: '100%',
padding: theme.spacing(2, 3),
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
chip: { chip: {
margin: '0.25rem', margin: '0.25rem',
}, },

View File

@ -1,17 +1,14 @@
import { Chip } from '@mui/material'; import { Chip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from './FeatureOverviewExecutionChips.styles'; import { useStyles } from './ConstraintItem.styles';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
interface IFeatureOverviewExecutionChipsProps { interface IConstraintItemProps {
value: string[]; value: string[];
text: string; text: string;
} }
const FeatureOverviewExecutionChips = ({ export const ConstraintItem = ({ value, text }: IConstraintItemProps) => {
value,
text,
}: IFeatureOverviewExecutionChipsProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -44,5 +41,3 @@ const FeatureOverviewExecutionChips = ({
</div> </div>
); );
}; };
export default FeatureOverviewExecutionChips;

View File

@ -9,4 +9,10 @@ export const useStyles = makeStyles()(theme => ({
valueSeparator: { valueSeparator: {
color: theme.palette.grey[700], color: theme.palette.grey[700],
}, },
summary: {
width: '100%',
padding: theme.spacing(2, 3),
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
})); }));

View File

@ -1,37 +1,29 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { import { Box, Chip } from '@mui/material';
IFeatureStrategy, import { IFeatureStrategy } from 'interfaces/strategy';
IFeatureStrategyParameters,
IConstraint,
} from 'interfaces/strategy';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle'; import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator'; import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import FeatureOverviewExecutionChips from './FeatureOverviewExecutionChips/FeatureOverviewExecutionChips'; import { ConstraintItem } from './ConstraintItem/ConstraintItem';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies'; import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { FeatureOverviewSegment } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment'; import { FeatureOverviewSegment } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment';
import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList'; import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
import { useStyles } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewExecution/FeatureOverviewExecution.styles'; import { useStyles } from './StrategyExecution.styles';
import { import {
parseParameterString, parseParameterString,
parseParameterNumber, parseParameterNumber,
parseParameterStrings, parseParameterStrings,
} from 'utils/parseParameter'; } from 'utils/parseParameter';
interface IFeatureOverviewExecutionProps { interface IStrategyExecutionProps {
parameters: IFeatureStrategyParameters;
constraints?: IConstraint[];
strategy: IFeatureStrategy; strategy: IFeatureStrategy;
percentageFill?: string; percentageFill?: string;
} }
const FeatureOverviewExecution = ({ export const StrategyExecution = ({ strategy }: IStrategyExecutionProps) => {
parameters, const { parameters, constraints = [] } = strategy;
constraints = [],
strategy,
}: IFeatureOverviewExecutionProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { strategies } = useStrategies(); const { strategies } = useStrategies();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -52,51 +44,51 @@ const FeatureOverviewExecution = ({
case 'rollout': case 'rollout':
case 'Rollout': case 'Rollout':
return ( return (
<Fragment key={key}> <Box
<p> className={styles.summary}
{parameters[key]}% of your base{' '} key={key}
{constraints.length > 0 sx={{ display: 'flex', alignItems: 'center' }}
? 'who match constraints' >
: ''}{' '}
is included.
</p>
<PercentageCircle <PercentageCircle
percentage={parseParameterNumber( percentage={parseParameterNumber(
parameters[key] parameters[key]
)} )}
styles={{
width: '2rem',
height: '2rem',
marginRight: '1rem',
}}
/> />
</Fragment> <div>
<Chip
color="success"
variant="outlined"
size="small"
label={`${parameters[key]}%`}
/>{' '}
of your base{' '}
{constraints.length > 0
? 'who match constraints'
: ''}{' '}
is included.
</div>
</Box>
); );
case 'userIds': case 'userIds':
case 'UserIds': case 'UserIds':
const users = parseParameterStrings(parameters[key]); const users = parseParameterStrings(parameters[key]);
return ( return (
<FeatureOverviewExecutionChips <ConstraintItem key={key} value={users} text="user" />
key={key}
value={users}
text="user"
/>
); );
case 'hostNames': case 'hostNames':
case 'HostNames': case 'HostNames':
const hosts = parseParameterStrings(parameters[key]); const hosts = parseParameterStrings(parameters[key]);
return ( return (
<FeatureOverviewExecutionChips <ConstraintItem key={key} value={hosts} text={'host'} />
key={key}
value={hosts}
text={'host'}
/>
); );
case 'IPs': case 'IPs':
const IPs = parseParameterStrings(parameters[key]); const IPs = parseParameterStrings(parameters[key]);
return ( return <ConstraintItem key={key} value={IPs} text={'IP'} />;
<FeatureOverviewExecutionChips
key={key}
value={IPs}
text={'IP'}
/>
);
case 'stickiness': case 'stickiness':
case 'groupId': case 'groupId':
return null; return null;
@ -117,10 +109,7 @@ const FeatureOverviewExecution = ({
); );
return ( return (
<Fragment key={param?.name}> <Fragment key={param?.name}>
<FeatureOverviewExecutionChips <ConstraintItem value={values} text={param.name} />
value={values}
text={param.name}
/>
<ConditionallyRender <ConditionallyRender
condition={notLastItem} condition={notLastItem}
show={<StrategySeparator text="AND" />} show={<StrategySeparator text="AND" />}
@ -130,13 +119,21 @@ const FeatureOverviewExecution = ({
case 'percentage': case 'percentage':
return ( return (
<Fragment key={param?.name}> <Fragment key={param?.name}>
<p> <div>
{strategy?.parameters[param.name]}% of your base{' '} <Chip
size="small"
variant="outlined"
color="success"
label={`${
strategy?.parameters[param.name]
}%`}
/>{' '}
of your base{' '}
{constraints?.length > 0 {constraints?.length > 0
? 'who match constraints' ? 'who match constraints'
: ''}{' '} : ''}{' '}
is included. is included.
</p> </div>
<PercentageCircle <PercentageCircle
percentage={parseParameterNumber( percentage={parseParameterNumber(
strategy.parameters[param.name] strategy.parameters[param.name]
@ -266,12 +263,21 @@ const FeatureOverviewExecution = ({
/> />
<ConditionallyRender <ConditionallyRender
condition={strategy.name === 'default'} condition={strategy.name === 'default'}
show={<p>The standard strategy is on for all users.</p>} show={
<Box sx={{ width: '100%' }} className={styles.summary}>
The standard strategy is{' '}
<Chip
variant="outlined"
size="small"
color="success"
label="ON"
/>{' '}
for all users.
</Box>
}
/> />
{renderParameters()} {renderParameters()}
{renderCustomStrategy()} {renderCustomStrategy()}
</> </>
); );
}; };
export default FeatureOverviewExecution;

View File

@ -2,18 +2,20 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
container: { container: {
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadiusMedium,
border: `1px solid ${theme.palette.grey[300]}`, border: `1px solid ${theme.palette.grey[300]}`,
'& + &': { '& + &': {
marginTop: '1rem', marginTop: '1rem',
}, },
background: theme.palette.background.default,
}, },
header: { header: {
padding: '0.5rem', padding: theme.spacing(0.5, 2),
display: 'flex', display: 'flex',
gap: '0.5rem', gap: '0.5rem',
alignItems: 'center', alignItems: 'center',
borderBottom: `1px solid ${theme.palette.grey[300]}`, borderBottom: `1px solid ${theme.palette.grey[300]}`,
fontWeight: theme.typography.fontWeightMedium,
}, },
icon: { icon: {
fill: theme.palette.inactiveIcon, fill: theme.palette.inactiveIcon,
@ -25,7 +27,6 @@ export const useStyles = makeStyles()(theme => ({
body: { body: {
padding: '1rem', padding: '1rem',
display: 'grid', display: 'grid',
gap: '1rem',
justifyItems: 'center', justifyItems: 'center',
}, },
})); }));

View File

@ -1,5 +1,5 @@
import { Edit } from '@mui/icons-material'; import { DragIndicator, Edit } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles'; import { styled, useTheme, IconButton } from '@mui/material';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { IFeatureStrategy } from 'interfaces/strategy'; import { IFeatureStrategy } from 'interfaces/strategy';
import { import {
@ -8,28 +8,36 @@ import {
} from 'utils/strategyNames'; } from 'utils/strategyNames';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import FeatureOverviewExecution from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewExecution/FeatureOverviewExecution';
import { useStyles } from './FeatureOverviewEnvironmentStrategy.styles';
import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { FeatureStrategyRemove } from 'component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove'; import { FeatureStrategyRemove } from 'component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
import { useStyles } from './StrategyItem.styles';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IFeatureOverviewEnvironmentStrategyProps { interface IStrategyItemProps {
environmentId: string; environmentId: string;
strategy: IFeatureStrategy; strategy: IFeatureStrategy;
isDraggable?: boolean;
} }
const FeatureOverviewEnvironmentStrategy = ({ const DragIcon = styled(IconButton)(({ theme }) => ({
padding: 0,
cursor: 'inherit',
transition: 'color 0.2s ease-in-out',
}));
export const StrategyItem = ({
environmentId, environmentId,
strategy, strategy,
}: IFeatureOverviewEnvironmentStrategyProps) => { isDraggable,
}: IStrategyItemProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const theme = useTheme(); const theme = useTheme();
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const Icon = getFeatureStrategyIcon(strategy.name); const Icon = getFeatureStrategyIcon(strategy.name);
const { parameters, constraints } = strategy;
const editStrategyPath = formatEditStrategyPath( const editStrategyPath = formatEditStrategyPath(
projectId, projectId,
@ -41,6 +49,17 @@ const FeatureOverviewEnvironmentStrategy = ({
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
<ConditionallyRender
condition={Boolean(isDraggable)}
show={() => (
<DragIcon disableRipple disabled size="small">
<DragIndicator
titleAccess="Drag to reorder"
cursor="grab"
/>
</DragIcon>
)}
/>
<Icon className={styles.icon} /> <Icon className={styles.icon} />
<StringTruncator <StringTruncator
maxWidth="150" maxWidth="150"
@ -68,15 +87,11 @@ const FeatureOverviewEnvironmentStrategy = ({
</div> </div>
</div> </div>
<div className={styles.body}> <div className={styles.body}>
<FeatureOverviewExecution <StrategyExecution
parameters={parameters}
strategy={strategy} strategy={strategy}
constraints={constraints}
percentageFill={theme.palette.grey[200]} percentageFill={theme.palette.grey[200]}
/> />
</div> </div>
</div> </div>
); );
}; };
export default FeatureOverviewEnvironmentStrategy;

View File

@ -1,27 +1,22 @@
import { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle'; import { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle';
import { useStyles } from '../FeatureOverviewEnvironment.styles';
import { FeatureMetricsStats } from 'component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats'; import { FeatureMetricsStats } from 'component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats';
import { SectionSeparator } from '../SectionSeparator/SectionSeparator';
interface IFeatureOverviewEnvironmentFooterProps { interface IEnvironmentFooterProps {
environmentMetric?: IFeatureEnvironmentMetrics; environmentMetric?: IFeatureEnvironmentMetrics;
} }
const FeatureOverviewEnvironmentFooter = ({ export const EnvironmentFooter = ({
environmentMetric, environmentMetric,
}: IFeatureOverviewEnvironmentFooterProps) => { }: IEnvironmentFooterProps) => {
const { classes: styles } = useStyles();
if (!environmentMetric) { if (!environmentMetric) {
return null; return null;
} }
return ( return (
<> <>
<div className={styles.resultInfo}> <SectionSeparator>Feature toggle exposure</SectionSeparator>
<div className={styles.leftWing} />
<div className={styles.separatorText}>Result</div>
<div className={styles.rightWing} />
</div>
<div> <div>
<FeatureMetricsStats <FeatureMetricsStats
totalYes={environmentMetric.yes} totalYes={environmentMetric.yes}
@ -32,4 +27,3 @@ const FeatureOverviewEnvironmentFooter = ({
</> </>
); );
}; };
export default FeatureOverviewEnvironmentFooter;

View File

@ -2,13 +2,13 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
featureOverviewEnvironment: { featureOverviewEnvironment: {
borderRadius: '12.5px', borderRadius: theme.shape.borderRadiusLarge,
padding: '0.2rem', marginBottom: theme.spacing(2),
marginBottom: '1rem', background: theme.palette.background.default,
backgroundColor: '#fff',
}, },
accordionContainer: { accordion: {
width: '100%', boxShadow: 'none',
background: 'none',
}, },
accordionHeader: { accordionHeader: {
boxShadow: 'none', boxShadow: 'none',
@ -22,16 +22,26 @@ export const useStyles = makeStyles()(theme => ({
padding: '0.5rem', padding: '0.5rem',
}, },
}, },
accordionDetails: {
padding: theme.spacing(3),
background: theme.palette.secondaryContainer,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
borderBottom: `4px solid ${theme.palette.primary.light}`,
},
accordionDetailsDisabled: {
borderBottom: `4px solid ${theme.palette.dividerAlternative}`,
},
accordionBody: { accordionBody: {
width: '100%', width: '100%',
position: 'relative', position: 'relative',
paddingBottom: '1rem', paddingBottom: theme.spacing(2),
}, },
header: { header: {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
flexDirection: 'column', flexDirection: 'column',
paddingTop: '1.5rem', // paddingTop: '1.5rem',
}, },
headerTitle: { headerTitle: {
display: 'flex', display: 'flex',
@ -46,14 +56,6 @@ export const useStyles = makeStyles()(theme => ({
marginBottom: '0.5rem', marginBottom: '0.5rem',
}, },
}, },
disabledIndicatorPos: {
position: 'absolute',
top: '15px',
left: '20px',
[theme.breakpoints.down(560)]: {
top: '13px',
},
},
iconContainer: { iconContainer: {
backgroundColor: theme.palette.primary.light, backgroundColor: theme.palette.primary.light,
borderRadius: '50%', borderRadius: '50%',
@ -69,32 +71,14 @@ export const useStyles = makeStyles()(theme => ({
width: '17px', width: '17px',
height: '17px', height: '17px',
}, },
resultInfo: {
display: 'flex',
alignItems: 'center',
margin: '1rem 0',
},
leftWing: {
height: '2px',
backgroundColor: theme.palette.grey[300],
width: '90%',
},
separatorText: {
fontSize: theme.fontSizes.smallBody,
textAlign: 'center',
padding: '0 1rem',
},
rightWing: {
height: '2px',
backgroundColor: theme.palette.grey[300],
width: '90%',
},
linkContainer: { linkContainer: {
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
marginBottom: '1rem', marginBottom: '1rem',
}, },
truncator: { truncator: {
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightMedium,
[theme.breakpoints.down(560)]: { [theme.breakpoints.down(560)]: {
textAlign: 'center', textAlign: 'center',
}, },

View File

@ -1,4 +1,12 @@
import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material'; import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Chip,
useTheme,
} from '@mui/material';
import classNames from 'classnames';
import { ExpandMore } from '@mui/icons-material'; import { ExpandMore } from '@mui/icons-material';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics'; import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
@ -8,14 +16,14 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon'; import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useStyles } from './FeatureOverviewEnvironment.styles'; import { useStyles } from './FeatureOverviewEnvironment.styles';
import FeatureOverviewEnvironmentBody from './FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody'; import EnvironmentAccordionBody from './EnvironmentAccordionBody/EnvironmentAccordionBody';
import FeatureOverviewEnvironmentFooter from './FeatureOverviewEnvironmentFooter/FeatureOverviewEnvironmentFooter'; import { EnvironmentFooter } from './EnvironmentFooter/EnvironmentFooter';
import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics'; import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu'; import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds'; import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons'; import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
import { Badge } from 'component/common/Badge/Badge'; // import { Badge } from 'component/common/Badge/Badge';
interface IFeatureOverviewEnvironmentProps { interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment; env: IFeatureEnvironment;
@ -25,6 +33,7 @@ const FeatureOverviewEnvironment = ({
env, env,
}: IFeatureOverviewEnvironmentProps) => { }: IFeatureOverviewEnvironmentProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const theme = useTheme();
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const { metrics } = useFeatureMetrics(projectId, featureId); const { metrics } = useFeatureMetrics(projectId, featureId);
@ -38,38 +47,57 @@ const FeatureOverviewEnvironment = ({
featureEnvironment => featureEnvironment.name === env.name featureEnvironment => featureEnvironment.name === env.name
); );
const getOverviewText = () => {
if (env.enabled) {
return `${environmentMetric?.yes} received this feature because the following strategies are executing`;
}
return `This environment is disabled, which means that none of your strategies are executing`;
};
return ( return (
<div className={styles.featureOverviewEnvironment}> <div
className={styles.featureOverviewEnvironment}
style={{
background: !env.enabled
? theme.palette.neutral.light
: theme.palette.background.default,
}}
>
<Accordion <Accordion
style={{ boxShadow: 'none' }} className={styles.accordion}
data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${env.name}`} data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${env.name}`}
> >
<AccordionSummary <AccordionSummary
className={styles.accordionHeader} className={styles.accordionHeader}
expandIcon={<ExpandMore titleAccess="Toggle" />} expandIcon={<ExpandMore titleAccess="Toggle" />}
> >
<div className={styles.header} data-loading> <div
className={styles.header}
data-loading
style={{
color: !env.enabled
? theme.palette.text.disabled
: theme.palette.text.primary,
}}
>
<div className={styles.headerTitle}> <div className={styles.headerTitle}>
<EnvironmentIcon <EnvironmentIcon
enabled={env.enabled} enabled={env.enabled}
className={styles.headerIcon} className={styles.headerIcon}
/> />
<p> <div>
Feature toggle execution for&nbsp;
<StringTruncator <StringTruncator
text={env.name} text={env.name}
className={styles.truncator} className={styles.truncator}
maxWidth="100" maxWidth="100"
maxLength={15} maxLength={15}
/> />
</p> <ConditionallyRender
condition={!env.enabled}
show={
<Chip
size="small"
variant="outlined"
// severity="disabled"
label="Disabled"
sx={{ ml: 1 }}
/>
}
/>
</div>
</div> </div>
<div className={styles.container}> <div className={styles.container}>
<FeatureStrategyMenu <FeatureStrategyMenu
@ -83,17 +111,6 @@ const FeatureOverviewEnvironment = ({
strategies={featureEnvironment?.strategies} strategies={featureEnvironment?.strategies}
/> />
</div> </div>
<ConditionallyRender
condition={!env.enabled}
show={
<Badge
color="warning"
className={styles.disabledIndicatorPos}
>
Disabled
</Badge>
}
/>
</div> </div>
<FeatureOverviewEnvironmentMetrics <FeatureOverviewEnvironmentMetrics
@ -101,26 +118,41 @@ const FeatureOverviewEnvironment = ({
/> />
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails
<div className={styles.accordionContainer}> className={classNames(styles.accordionDetails, {
<FeatureOverviewEnvironmentBody [styles.accordionDetailsDisabled]: !env.enabled,
featureEnvironment={featureEnvironment} })}
getOverviewText={getOverviewText} >
/> <EnvironmentAccordionBody
<ConditionallyRender featureEnvironment={featureEnvironment}
condition={ isDisabled={!env.enabled}
// @ts-expect-error />
featureEnvironment?.strategies?.length > 0 <ConditionallyRender
} condition={
show={ (featureEnvironment?.strategies?.length || 0) > 0
<FeatureOverviewEnvironmentFooter }
// @ts-expect-error show={
env={env} <>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 1,
}}
>
<FeatureStrategyMenu
label="Add strategy"
projectId={projectId}
featureId={featureId}
environmentId={env.name}
/>
</Box>
<EnvironmentFooter
environmentMetric={environmentMetric} environmentMetric={environmentMetric}
/> />
} </>
/> }
</div> />
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
</div> </div>

View File

@ -1,57 +0,0 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import FeatureOverviewEnvironmentStrategies from '../FeatureOverviewEnvironmentStrategies/FeatureOverviewEnvironmentStrategies';
import { useStyles } from '../FeatureOverviewEnvironment.styles';
import { IFeatureEnvironment } from 'interfaces/featureToggle';
import { FeatureStrategyEmpty } from 'component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
interface IFeatureOverviewEnvironmentBodyProps {
getOverviewText: () => string;
featureEnvironment?: IFeatureEnvironment;
}
const FeatureOverviewEnvironmentBody = ({
featureEnvironment,
getOverviewText,
}: IFeatureOverviewEnvironmentBodyProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { classes: styles } = useStyles();
if (!featureEnvironment) {
return null;
}
return (
<div className={styles.accordionBody}>
<div className={styles.accordionBodyInnerContainer}>
<div className={styles.resultInfo}>
<div className={styles.leftWing} />
<div className={styles.separatorText}>
{getOverviewText()}
</div>
<div className={styles.rightWing} />
</div>
<ConditionallyRender
condition={featureEnvironment?.strategies.length > 0}
show={
<>
<FeatureOverviewEnvironmentStrategies
strategies={featureEnvironment?.strategies}
environmentName={featureEnvironment.name}
/>
</>
}
elseShow={
<FeatureStrategyEmpty
projectId={projectId}
featureId={featureId}
environmentId={featureEnvironment.name}
/>
}
/>
</div>
</div>
);
};
export default FeatureOverviewEnvironmentBody;

View File

@ -20,7 +20,7 @@ export const useStyles = makeStyles()(theme => ({
}, },
}, },
infoParagraph: { infoParagraph: {
maxWidth: '215px', maxWidth: '270px',
marginTop: '0.25rem', marginTop: '0.25rem',
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
[theme.breakpoints.down(700)]: { [theme.breakpoints.down(700)]: {

View File

@ -1,26 +0,0 @@
import { IFeatureStrategy } from 'interfaces/strategy';
import FeatureOverviewEnvironmentStrategy from './FeatureOverviewEnvironmentStrategy/FeatureOverviewEnvironmentStrategy';
interface IFeatureOverviewEnvironmentStrategiesProps {
strategies: IFeatureStrategy[];
environmentName: string;
}
const FeatureOverviewEnvironmentStrategies = ({
strategies,
environmentName,
}: IFeatureOverviewEnvironmentStrategiesProps) => {
return (
<>
{strategies.map(strategy => (
<FeatureOverviewEnvironmentStrategy
key={strategy.id}
strategy={strategy}
environmentId={environmentName}
/>
))}
</>
);
};
export default FeatureOverviewEnvironmentStrategies;

View File

@ -0,0 +1,35 @@
import { FC } from 'react';
import { styled } from '@mui/material';
const SeparatorContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '1rem 0',
position: 'relative',
'&:before': {
content: '""',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
height: 2,
width: '100%',
backgroundColor: theme.palette.divider,
},
}));
const SeparatorContent = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.subHeader,
textAlign: 'center',
padding: '0 1rem',
background: theme.palette.secondaryContainer,
position: 'relative',
maxWidth: '80%',
color: theme.palette.text.secondary,
}));
export const SectionSeparator: FC = ({ children }) => (
<SeparatorContainer>
<SeparatorContent>{children}</SeparatorContent>
</SeparatorContainer>
);

View File

@ -11,13 +11,13 @@ const FeatureOverviewEnvironments = () => {
const { environments } = feature; const { environments } = feature;
const renderEnvironments = () => { return (
return environments?.map(env => { <>
return <FeatureOverviewEnvironment env={env} key={env.name} />; {environments?.map(env => (
}); <FeatureOverviewEnvironment env={env} key={env.name} />
}; ))}
</>
return <>{renderEnvironments()}</>; );
}; };
export default FeatureOverviewEnvironments; export default FeatureOverviewEnvironments;

View File

@ -2,7 +2,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
eventEntry: { eventEntry: {
border: `1px solid ${theme.palette.grey[100]}`, border: `1px solid ${theme.palette.neutral.light}`,
padding: '1rem', padding: '1rem',
margin: '1rem 0', margin: '1rem 0',
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,

View File

@ -14,7 +14,7 @@ interface IPlaygroundEditorProps {
const StyledEditorHeader = styled('aside')(({ theme }) => ({ const StyledEditorHeader = styled('aside')(({ theme }) => ({
height: '50px', height: '50px',
backgroundColor: theme.palette.grey[100], backgroundColor: theme.palette.neutral.light,
borderTopRightRadius: theme.shape.borderRadiusMedium, borderTopRightRadius: theme.shape.borderRadiusMedium,
borderTopLeftRadius: theme.shape.borderRadiusMedium, borderTopLeftRadius: theme.shape.borderRadiusMedium,
padding: theme.spacing(1, 2), padding: theme.spacing(1, 2),

View File

@ -16,7 +16,7 @@ export const useStyles = makeStyles()(theme => ({
}, },
'&:hover': { '&:hover': {
transition: 'background-color 0.2s ease-in-out', transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.grey[100], backgroundColor: theme.palette.neutral.light,
}, },
}, },
header: { header: {

View File

@ -10,7 +10,7 @@ export const useStyles = makeStyles()(theme => ({
}, },
headerContainer: { display: 'flex', padding: '0.5rem' }, headerContainer: { display: 'flex', padding: '0.5rem' },
divider: { divider: {
backgroundColor: theme.palette.grey[100], backgroundColor: theme.palette.neutral.light,
height: '1px', height: '1px',
width: '100%', width: '100%',
}, },

View File

@ -1,4 +1,8 @@
import { IFeatureStrategyPayload, IFeatureStrategy } from 'interfaces/strategy'; import {
IFeatureStrategyPayload,
IFeatureStrategy,
IFeatureStrategySortOrder,
} from 'interfaces/strategy';
import useAPI from '../useApi/useApi'; import useAPI from '../useApi/useApi';
const useFeatureStrategyApi = () => { const useFeatureStrategyApi = () => {
@ -52,10 +56,26 @@ const useFeatureStrategyApi = () => {
await makeRequest(req.caller, req.id); await makeRequest(req.caller, req.id);
}; };
const setStrategiesSortOrder = async (
projectId: string,
featureId: string,
environmentId: string,
payload: IFeatureStrategySortOrder[]
): Promise<void> => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/set-sort-order`;
const req = createRequest(
path,
{ method: 'POST', body: JSON.stringify(payload) },
'setStrategiesSortOrderOnFeature'
);
await makeRequest(req.caller, req.id);
};
return { return {
addStrategyToFeature, addStrategyToFeature,
updateStrategyOnFeature, updateStrategyOnFeature,
deleteStrategyFromFeature, deleteStrategyFromFeature,
setStrategiesSortOrder,
loading, loading,
errors, errors,
}; };

View File

@ -51,3 +51,8 @@ export interface IConstraint {
operator: Operator; operator: Operator;
contextName: string; contextName: string;
} }
export interface IFeatureStrategySortOrder {
id: string;
sortOrder: number;
}

View File

@ -26,6 +26,9 @@ export default createTheme({
fontSize: '1.5rem', fontSize: '1.5rem',
lineHeight: 1.875, lineHeight: 1.875,
}, },
caption: {
fontSize: `${12 / 16}rem`,
},
}, },
fontSizes: { fontSizes: {
mainHeader: '1.25rem', mainHeader: '1.25rem',
@ -287,5 +290,28 @@ export default createTheme({
}, },
}, },
}, },
MuiChip: {
styleOverrides: {
root: ({ ownerState, theme }) => ({
...(ownerState.variant === 'outlined' &&
ownerState.size === 'small' && {
borderRadius: theme.shape.borderRadius,
margin: 0,
borderWidth: 1,
borderStyle: 'solid',
fontWeight: theme.typography.fontWeightBold,
fontSize: theme.typography.caption.fontSize,
...(ownerState.color === 'success' && {
backgroundColor: colors.green[50],
borderColor: theme.palette.success.border,
color: theme.palette.success.dark,
}),
...(ownerState.color === 'default' && {
color: theme.palette.text.secondary,
}),
}),
}),
},
},
}, },
}); });

View File

@ -2,6 +2,7 @@ import LocationOnIcon from '@mui/icons-material/LocationOn';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import LanguageIcon from '@mui/icons-material/Language'; import LanguageIcon from '@mui/icons-material/Language';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import CodeIcon from '@mui/icons-material/Code';
import { ReactComponent as RolloutIcon } from 'assets/icons/rollout.svg'; import { ReactComponent as RolloutIcon } from 'assets/icons/rollout.svg';
import { ElementType } from 'react'; import { ElementType } from 'react';
@ -11,6 +12,8 @@ export const formatStrategyName = (strategyName: string): string => {
export const getFeatureStrategyIcon = (strategyName: string): ElementType => { export const getFeatureStrategyIcon = (strategyName: string): ElementType => {
switch (strategyName) { switch (strategyName) {
case 'default':
return PowerSettingsNewIcon;
case 'remoteAddress': case 'remoteAddress':
return LanguageIcon; return LanguageIcon;
case 'flexibleRollout': case 'flexibleRollout':
@ -20,7 +23,7 @@ export const getFeatureStrategyIcon = (strategyName: string): ElementType => {
case 'applicationHostname': case 'applicationHostname':
return LocationOnIcon; return LocationOnIcon;
default: default:
return PowerSettingsNewIcon; return CodeIcon;
} }
}; };