1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +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: {
display: 'flex',
alignItems: 'center',
alignItems: 'stretch',
[theme.breakpoints.down(710)]: { flexDirection: 'column' },
},
headerContainer: {
@ -48,12 +48,11 @@ export const useStyles = makeStyles()(theme => ({
},
headerValuesContainerWrapper: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'stretch',
},
headerValuesContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
},
headerValues: {
fontSize: theme.fontSizes.smallBody,
@ -106,7 +105,10 @@ export const useStyles = makeStyles()(theme => ({
},
display: 'inline-flex',
},
headerSelect: { marginRight: '1rem', width: '200px' },
headerSelect: {
marginRight: '1rem',
width: '200px',
},
chip: {
margin: '0 0.5rem 0.5rem 0',
},
@ -121,7 +123,7 @@ export const useStyles = makeStyles()(theme => ({
},
},
accordionDetails: {
borderTop: `1px solid ${theme.palette.grey[300]}`,
borderTop: `1px dashed ${theme.palette.grey[300]}`,
display: 'flex',
flexDirection: 'column',
},
@ -132,33 +134,16 @@ export const useStyles = makeStyles()(theme => ({
},
summary: {
border: 'none',
padding: '0.25rem 1rem',
padding: theme.spacing(0.5, 3),
'&:hover .valuesExpandLabel': {
textDecoration: 'underline',
},
},
settingsParagraph: {
display: 'flex',
alignItems: 'center',
padding: '0.5rem 0',
},
settingsIcon: {
height: '32.5px',
width: '32.5px',
marginRight: '0.5rem',
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%' },
}));

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

View File

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

View File

@ -3,8 +3,8 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
width: '100%',
display: 'grid',
gap: '1rem',
display: 'flex',
flexDirection: 'column',
},
help: {
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 React, { forwardRef, useImperativeHandle } from 'react';
import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion';
import produce from 'immer';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
@ -8,8 +10,7 @@ import { objectId } from 'utils/objectId';
import { useStyles } from './ConstraintAccordionList.styles';
import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Button, Tooltip } from '@mui/material';
import { Help } from '@mui/icons-material';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
interface IConstraintAccordionListProps {
constraints: IConstraint[];
@ -129,16 +130,22 @@ export const ConstraintAccordionList = forwardRef<
}
/>
{constraints.map((constraint, index) => (
<ConstraintAccordion
key={objectId(constraint)}
constraint={constraint}
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 key={`${constraint.contextName}-${index}`}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text="AND" />}
/>
<ConstraintAccordion
key={objectId(constraint)}
constraint={constraint}
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>
);

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { IConstraint } from 'interfaces/strategy';
import { ConstraintAccordionViewBody } from './ConstraintAccordionViewBody/ConstraintAccordionViewBody';
import { ConstraintAccordionViewHeader } from './ConstraintAccordionViewHeader/ConstraintAccordionViewHeader';
import { oneOf } from 'utils/oneOf';
@ -9,9 +9,8 @@ import {
numOperators,
semVerOperators,
} from 'constants/operators';
import { useStyles } from '../ConstraintAccordion.styles';
import { useState } from 'react';
interface IConstraintAccordionViewProps {
constraint: IConstraint;
onDelete?: () => void;
@ -46,7 +45,7 @@ export const ConstraintAccordionView = ({
sx={{ cursor: expandable ? 'pointer' : 'default' }}
>
<AccordionSummary
className={styles.summary}
classes={{ root: styles.summary }}
expandIcon={null}
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 StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useState } from 'react';
import { stringOperators } from 'constants/operators';
import { IConstraint } from 'interfaces/strategy';
import { oneOf } from 'utils/oneOf';
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 { 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 {
constraint: IConstraint;
@ -18,7 +17,8 @@ interface IConstraintAccordionViewBodyProps {
export const ConstraintAccordionViewBody = ({
constraint,
}: IConstraintAccordionViewBodyProps) => {
const { classes: styles } = useStyles();
const { classes: styles } = useAccordionStyles();
const { classes } = useStyles();
const { locationSettings } = useLocationSettings();
return (
@ -29,7 +29,7 @@ export const ConstraintAccordionViewBody = ({
Boolean(constraint.caseInsensitive)
}
show={
<p className={styles.settingsParagraph}>
<p className={classes.settingsParagraph}>
<TextFormatOutlined className={styles.settingsIcon} />{' '}
Case insensitive setting is active
</p>
@ -39,7 +39,7 @@ export const ConstraintAccordionViewBody = ({
<ConditionallyRender
condition={Boolean(constraint.inverted)}
show={
<p className={styles.settingsParagraph}>
<p className={classes.settingsParagraph}>
<ImportExportOutlined className={styles.settingsIcon} />{' '}
Operator is negated
</p>
@ -56,70 +56,3 @@ export const ConstraintAccordionViewBody = ({
</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 { IConstraint } from 'interfaces/strategy';
import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
import React from 'react';
import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo';
import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions';
import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
interface IConstraintAccordionViewHeaderProps {
constraint: IConstraint;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,34 @@
import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FC, forwardRef } from 'react';
export const StyledIconWrapper = styled('div')<{
marginright?: string;
}>(({ theme, marginright }) => ({
export const StyledIconWrapperBase = styled('div')<{
prefix?: boolean;
}>(({ theme }) => ({
backgroundColor: theme.palette.grey[200],
width: 28,
height: 48,
display: 'inline-flex',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '10px 0',
alignSelf: 'stretch',
color: theme.palette.primary.main,
marginRight: marginright ? marginright : '1rem',
marginTop: 'auto',
marginBottom: 'auto',
marginRight: '1rem',
borderRadius: theme.shape.borderRadius,
}));
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 => ({
container: {
padding: '0.5rem 0.75rem',
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.grey[200],
lineHeight: 1.25,

View File

@ -5,10 +5,12 @@ import React from 'react';
interface IConstraintOperatorProps {
constraint: IConstraint;
hasPrefix?: boolean;
}
export const ConstraintOperator = ({
constraint,
hasPrefix,
}: IConstraintOperatorProps) => {
const { classes: styles } = useStyles();
@ -16,7 +18,13 @@ export const ConstraintOperator = ({
const operatorText = formatOperatorDescription(constraint.operator);
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.text}>{operatorText}</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 {
text: string;
text: 'AND' | 'OR';
}
export const StrategySeparator = ({ text }: IStrategySeparatorProps) => {
const theme = useTheme();
const StyledContainer = styled('div')(({ theme }) => ({
height: theme.spacing(2),
position: 'relative',
width: '100%',
}));
return (
<div
style={{
color: theme.palette.primary.main,
padding: '0.1rem 0.25rem',
border: `1px solid ${theme.palette.primary.main}`,
borderRadius: '0.25rem',
fontSize: theme.fontSizes.smallerBody,
backgroundColor: '#fff',
}}
>
{text}
</div>
);
};
const StyledContent = styled('div')(({ theme }) => ({
padding: theme.spacing(0.75, 1.5),
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallerBody,
backgroundColor: theme.palette.secondaryContainer,
borderRadius: theme.shape.borderRadius,
position: 'absolute',
zIndex: theme.zIndex.fab,
top: '50%',
left: theme.spacing(3),
transform: 'translateY(-50%)',
}));
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 => ({
emptyStateListItem: {
border: `2px dashed ${theme.palette.grey[100]}`,
border: `2px dashed ${theme.palette.neutral.light}`,
padding: '0.8rem',
textAlign: 'center',
display: 'flex',

View File

@ -1,14 +1,22 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
noItemsParagraph: {
margin: '1rem 0',
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
link: {
display: 'block',
margin: '1rem 0 0 0',
title: {
fontSize: theme.fontSizes.bodySize,
textAlign: 'center',
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
},
envName: {
fontWeight: 'bold',
description: {
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 StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useStyles } from './FeatureStrategyEmpty.styles';
import { Link } from 'react-router-dom';
import { Box } from '@mui/material';
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 { PresetCard } from './PresetCard/PresetCard';
import { useStyles } from './FeatureStrategyEmpty.styles';
import { formatUnknownError } from 'utils/formatUnknownError';
interface IFeatureStrategyEmptyProps {
projectId: string;
@ -15,30 +21,58 @@ export const FeatureStrategyEmpty = ({
environmentId,
}: IFeatureStrategyEmptyProps) => {
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 (
<NoItems>
<p className={styles.noItemsParagraph}>
No strategies added in the{' '}
<StringTruncator
text={environmentId}
maxWidth={'130'}
maxLength={15}
className={styles.envName}
/>{' '}
environment
</p>
<p className={styles.noItemsParagraph}>
<div className={styles.container}>
<div className={styles.title}>
You have not defined any strategies yet.
</div>
<p className={styles.description}>
Strategies added in this environment will only be executed if
the SDK is using an API key configured for this environment.
<a
className={styles.link}
href="https://docs.getunleash.io/user_guide/environments"
target="_blank"
rel="noreferrer"
>
Read more here
</a>
the SDK is using an{' '}
<Link to="/admin/api">API key configured</Link> for this
environment.
</p>
<FeatureStrategyMenu
label="Add your first strategy"
@ -46,6 +80,31 @@ export const FeatureStrategyEmpty = ({
featureId={featureId}
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 (
<StyledIcon>
<Tooltip title={formatStrategyName(strategyName)} arrow>
<Icon />
<>
<Icon />
</>
</Tooltip>
</StyledIcon>
);

View File

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

View File

@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
item: {
padding: theme.spacing(2),
background: theme.palette.grey[100],
background: theme.palette.secondaryContainer,
borderRadius: theme.spacing(2),
textAlign: 'center',
[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';
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: {
margin: '0.25rem',
},

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Edit } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { DragIndicator, Edit } from '@mui/icons-material';
import { styled, useTheme, IconButton } from '@mui/material';
import { Link } from 'react-router-dom';
import { IFeatureStrategy } from 'interfaces/strategy';
import {
@ -8,28 +8,36 @@ import {
} from 'utils/strategyNames';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
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 { FeatureStrategyRemove } from 'component/feature/FeatureStrategy/FeatureStrategyRemove/FeatureStrategyRemove';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
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;
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,
strategy,
}: IFeatureOverviewEnvironmentStrategyProps) => {
isDraggable,
}: IStrategyItemProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const theme = useTheme();
const { classes: styles } = useStyles();
const Icon = getFeatureStrategyIcon(strategy.name);
const { parameters, constraints } = strategy;
const editStrategyPath = formatEditStrategyPath(
projectId,
@ -41,6 +49,17 @@ const FeatureOverviewEnvironmentStrategy = ({
return (
<div className={styles.container}>
<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} />
<StringTruncator
maxWidth="150"
@ -68,15 +87,11 @@ const FeatureOverviewEnvironmentStrategy = ({
</div>
</div>
<div className={styles.body}>
<FeatureOverviewExecution
parameters={parameters}
<StrategyExecution
strategy={strategy}
constraints={constraints}
percentageFill={theme.palette.grey[200]}
/>
</div>
</div>
);
};
export default FeatureOverviewEnvironmentStrategy;

View File

@ -1,27 +1,22 @@
import { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle';
import { useStyles } from '../FeatureOverviewEnvironment.styles';
import { FeatureMetricsStats } from 'component/feature/FeatureView/FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats';
import { SectionSeparator } from '../SectionSeparator/SectionSeparator';
interface IFeatureOverviewEnvironmentFooterProps {
interface IEnvironmentFooterProps {
environmentMetric?: IFeatureEnvironmentMetrics;
}
const FeatureOverviewEnvironmentFooter = ({
export const EnvironmentFooter = ({
environmentMetric,
}: IFeatureOverviewEnvironmentFooterProps) => {
const { classes: styles } = useStyles();
}: IEnvironmentFooterProps) => {
if (!environmentMetric) {
return null;
}
return (
<>
<div className={styles.resultInfo}>
<div className={styles.leftWing} />
<div className={styles.separatorText}>Result</div>
<div className={styles.rightWing} />
</div>
<SectionSeparator>Feature toggle exposure</SectionSeparator>
<div>
<FeatureMetricsStats
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 => ({
featureOverviewEnvironment: {
borderRadius: '12.5px',
padding: '0.2rem',
marginBottom: '1rem',
backgroundColor: '#fff',
borderRadius: theme.shape.borderRadiusLarge,
marginBottom: theme.spacing(2),
background: theme.palette.background.default,
},
accordionContainer: {
width: '100%',
accordion: {
boxShadow: 'none',
background: 'none',
},
accordionHeader: {
boxShadow: 'none',
@ -22,16 +22,26 @@ export const useStyles = makeStyles()(theme => ({
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: {
width: '100%',
position: 'relative',
paddingBottom: '1rem',
paddingBottom: theme.spacing(2),
},
header: {
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
paddingTop: '1.5rem',
// paddingTop: '1.5rem',
},
headerTitle: {
display: 'flex',
@ -46,14 +56,6 @@ export const useStyles = makeStyles()(theme => ({
marginBottom: '0.5rem',
},
},
disabledIndicatorPos: {
position: 'absolute',
top: '15px',
left: '20px',
[theme.breakpoints.down(560)]: {
top: '13px',
},
},
iconContainer: {
backgroundColor: theme.palette.primary.light,
borderRadius: '50%',
@ -69,32 +71,14 @@ export const useStyles = makeStyles()(theme => ({
width: '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: {
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '1rem',
},
truncator: {
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightMedium,
[theme.breakpoints.down(560)]: {
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 { useFeature } from 'hooks/api/getters/useFeature/useFeature';
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 StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useStyles } from './FeatureOverviewEnvironment.styles';
import FeatureOverviewEnvironmentBody from './FeatureOverviewEnvironmentBody/FeatureOverviewEnvironmentBody';
import FeatureOverviewEnvironmentFooter from './FeatureOverviewEnvironmentFooter/FeatureOverviewEnvironmentFooter';
import EnvironmentAccordionBody from './EnvironmentAccordionBody/EnvironmentAccordionBody';
import { EnvironmentFooter } from './EnvironmentFooter/EnvironmentFooter';
import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
import { Badge } from 'component/common/Badge/Badge';
// import { Badge } from 'component/common/Badge/Badge';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
@ -25,6 +33,7 @@ const FeatureOverviewEnvironment = ({
env,
}: IFeatureOverviewEnvironmentProps) => {
const { classes: styles } = useStyles();
const theme = useTheme();
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { metrics } = useFeatureMetrics(projectId, featureId);
@ -38,38 +47,57 @@ const FeatureOverviewEnvironment = ({
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 (
<div className={styles.featureOverviewEnvironment}>
<div
className={styles.featureOverviewEnvironment}
style={{
background: !env.enabled
? theme.palette.neutral.light
: theme.palette.background.default,
}}
>
<Accordion
style={{ boxShadow: 'none' }}
className={styles.accordion}
data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${env.name}`}
>
<AccordionSummary
className={styles.accordionHeader}
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}>
<EnvironmentIcon
enabled={env.enabled}
className={styles.headerIcon}
/>
<p>
Feature toggle execution for&nbsp;
<div>
<StringTruncator
text={env.name}
className={styles.truncator}
maxWidth="100"
maxLength={15}
/>
</p>
<ConditionallyRender
condition={!env.enabled}
show={
<Chip
size="small"
variant="outlined"
// severity="disabled"
label="Disabled"
sx={{ ml: 1 }}
/>
}
/>
</div>
</div>
<div className={styles.container}>
<FeatureStrategyMenu
@ -83,17 +111,6 @@ const FeatureOverviewEnvironment = ({
strategies={featureEnvironment?.strategies}
/>
</div>
<ConditionallyRender
condition={!env.enabled}
show={
<Badge
color="warning"
className={styles.disabledIndicatorPos}
>
Disabled
</Badge>
}
/>
</div>
<FeatureOverviewEnvironmentMetrics
@ -101,26 +118,41 @@ const FeatureOverviewEnvironment = ({
/>
</AccordionSummary>
<AccordionDetails>
<div className={styles.accordionContainer}>
<FeatureOverviewEnvironmentBody
featureEnvironment={featureEnvironment}
getOverviewText={getOverviewText}
/>
<ConditionallyRender
condition={
// @ts-expect-error
featureEnvironment?.strategies?.length > 0
}
show={
<FeatureOverviewEnvironmentFooter
// @ts-expect-error
env={env}
<AccordionDetails
className={classNames(styles.accordionDetails, {
[styles.accordionDetailsDisabled]: !env.enabled,
})}
>
<EnvironmentAccordionBody
featureEnvironment={featureEnvironment}
isDisabled={!env.enabled}
/>
<ConditionallyRender
condition={
(featureEnvironment?.strategies?.length || 0) > 0
}
show={
<>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 1,
}}
>
<FeatureStrategyMenu
label="Add strategy"
projectId={projectId}
featureId={featureId}
environmentId={env.name}
/>
</Box>
<EnvironmentFooter
environmentMetric={environmentMetric}
/>
}
/>
</div>
</>
}
/>
</AccordionDetails>
</Accordion>
</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: {
maxWidth: '215px',
maxWidth: '270px',
marginTop: '0.25rem',
fontSize: theme.fontSizes.smallBody,
[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 renderEnvironments = () => {
return environments?.map(env => {
return <FeatureOverviewEnvironment env={env} key={env.name} />;
});
};
return <>{renderEnvironments()}</>;
return (
<>
{environments?.map(env => (
<FeatureOverviewEnvironment env={env} key={env.name} />
))}
</>
);
};
export default FeatureOverviewEnvironments;

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export const useStyles = makeStyles()(theme => ({
},
headerContainer: { display: 'flex', padding: '0.5rem' },
divider: {
backgroundColor: theme.palette.grey[100],
backgroundColor: theme.palette.neutral.light,
height: '1px',
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';
const useFeatureStrategyApi = () => {
@ -52,10 +56,26 @@ const useFeatureStrategyApi = () => {
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 {
addStrategyToFeature,
updateStrategyOnFeature,
deleteStrategyFromFeature,
setStrategiesSortOrder,
loading,
errors,
};

View File

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

View File

@ -26,6 +26,9 @@ export default createTheme({
fontSize: '1.5rem',
lineHeight: 1.875,
},
caption: {
fontSize: `${12 / 16}rem`,
},
},
fontSizes: {
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 LanguageIcon from '@mui/icons-material/Language';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import CodeIcon from '@mui/icons-material/Code';
import { ReactComponent as RolloutIcon } from 'assets/icons/rollout.svg';
import { ElementType } from 'react';
@ -11,6 +12,8 @@ export const formatStrategyName = (strategyName: string): string => {
export const getFeatureStrategyIcon = (strategyName: string): ElementType => {
switch (strategyName) {
case 'default':
return PowerSettingsNewIcon;
case 'remoteAddress':
return LanguageIcon;
case 'flexibleRollout':
@ -20,7 +23,7 @@ export const getFeatureStrategyIcon = (strategyName: string): ElementType => {
case 'applicationHostname':
return LocationOnIcon;
default:
return PowerSettingsNewIcon;
return CodeIcon;
}
};