1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Disable and enable strategies - frontend (#3582)

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
Tymoteusz Czech 2023-04-26 11:41:24 +02:00 committed by GitHub
parent 1e3f652311
commit 3bb09c5ce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 722 additions and 168 deletions

View File

@ -1,6 +1,5 @@
import { FC, ReactNode } from 'react';
import {
hasNameField,
IChange,
IChangeRequest,
IChangeRequestFeature,
@ -8,18 +7,8 @@ import {
import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert, Box, styled } from '@mui/material';
import {
StrategyTooltipLink,
StrategyDiff,
} from 'component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink';
import { StrategyExecution } from '../../../../feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import { ToggleStatusChange } from './ToggleStatusChange';
import {
StrategyAddedChange,
StrategyDeletedChange,
StrategyEditedChange,
} from './StrategyChange';
import { StrategyChange } from './StrategyChange';
import { VariantPatch } from './VariantPatch/VariantPatch';
const StyledSingleChangeBox = styled(Box, {
@ -74,6 +63,7 @@ export const Change: FC<{
const lastIndex = feature.defaultChange
? feature.changes.length + 1
: feature.changes.length;
return (
<StyledSingleChangeBox
key={objectId(change)}
@ -98,50 +88,17 @@ export const Change: FC<{
discard={discard}
/>
)}
{change.action === 'addStrategy' && (
<>
<StrategyAddedChange discard={discard}>
<StrategyTooltipLink change={change}>
<StrategyDiff
change={change}
feature={feature.name}
environmentName={changeRequest.environment}
project={changeRequest.project}
/>
</StrategyTooltipLink>
</StrategyAddedChange>
<StrategyExecution strategy={change.payload} />
</>
)}
{change.action === 'deleteStrategy' && (
<StrategyDeletedChange discard={discard}>
{hasNameField(change.payload) && (
<StrategyTooltipLink change={change}>
<StrategyDiff
change={change}
feature={feature.name}
environmentName={changeRequest.environment}
project={changeRequest.project}
/>
</StrategyTooltipLink>
)}
</StrategyDeletedChange>
)}
{change.action === 'updateStrategy' && (
<>
<StrategyEditedChange discard={discard}>
<StrategyTooltipLink change={change}>
<StrategyDiff
change={change}
feature={feature.name}
environmentName={changeRequest.environment}
project={changeRequest.project}
/>
</StrategyTooltipLink>
</StrategyEditedChange>
<StrategyExecution strategy={change.payload} />
</>
)}
{change.action === 'addStrategy' ||
change.action === 'deleteStrategy' ||
change.action === 'updateStrategy' ? (
<StrategyChange
discard={discard}
change={change}
featureName={feature.name}
environmentName={changeRequest.environment}
projectId={changeRequest.project}
/>
) : null}
{change.action === 'patchVariant' && (
<VariantPatch
feature={feature.name}

View File

@ -1,5 +1,21 @@
import { Box, styled, Typography } from '@mui/material';
import { FC, ReactNode } from 'react';
import { VFC, FC, ReactNode } from 'react';
import { Box, styled, Tooltip, Typography } from '@mui/material';
import BlockIcon from '@mui/icons-material/Block';
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
import {
StrategyDiff,
StrategyTooltipLink,
} from '../../StrategyTooltipLink/StrategyTooltipLink';
import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import {
IChangeRequestAddStrategy,
IChangeRequestDeleteStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { useCurrentStrategy } from './hooks/useCurrentStrategy';
import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { flexRow } from 'themes/themeStyles';
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
@ -7,7 +23,7 @@ export const ChangeItemWrapper = styled(Box)({
alignItems: 'center',
});
export const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({
const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
@ -20,55 +36,175 @@ const ChangeItemInfo: FC = styled(Box)(({ theme }) => ({
gap: theme.spacing(1),
}));
export const StrategyAddedChange: FC<{ discard?: ReactNode }> = ({
children,
discard,
}) => {
const hasNameField = (payload: unknown): payload is { name: string } =>
typeof payload === 'object' && payload !== null && 'name' in payload;
const DisabledEnabledState: VFC<{ disabled: boolean }> = ({ disabled }) => {
if (disabled) {
return (
<Tooltip
title="This strategy will not be taken into account when evaluating feature toggle."
arrow
sx={{ cursor: 'pointer' }}
>
<Badge color="disabled" icon={<BlockIcon />}>
Disabled
</Badge>
</Tooltip>
);
}
return (
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<Typography
sx={theme => ({
color: theme.palette.success.dark,
})}
>
+ Adding strategy:
</Typography>
{children}
</ChangeItemInfo>
{discard}
</ChangeItemCreateEditWrapper>
<Tooltip
title="This was disabled before and with this change it will be taken into account when evaluating feature toggle."
arrow
sx={{ cursor: 'pointer' }}
>
<Badge color="success" icon={<TrackChangesIcon />}>
Enabled
</Badge>
</Tooltip>
);
};
export const StrategyEditedChange: FC<{ discard?: ReactNode }> = ({
children,
discard,
}) => {
return (
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<Typography>Editing strategy:</Typography>
{children}
</ChangeItemInfo>
{discard}
</ChangeItemCreateEditWrapper>
);
const EditHeader: VFC<{
wasDisabled?: boolean;
willBeDisabled?: boolean;
}> = ({ wasDisabled = false, willBeDisabled = false }) => {
if (wasDisabled && willBeDisabled) {
return (
<Typography color="action.disabled">
Editing disabled strategy
</Typography>
);
}
if (!wasDisabled && willBeDisabled) {
return <Typography color="error.dark">Editing strategy</Typography>;
}
if (wasDisabled && !willBeDisabled) {
return <Typography color="success.dark">Editing strategy</Typography>;
}
return <Typography>Editing strategy:</Typography>;
};
export const StrategyDeletedChange: FC<{ discard?: ReactNode }> = ({
discard,
children,
}) => {
export const StrategyChange: VFC<{
discard?: ReactNode;
change:
| IChangeRequestAddStrategy
| IChangeRequestDeleteStrategy
| IChangeRequestUpdateStrategy;
environmentName: string;
featureName: string;
projectId: string;
}> = ({ discard, change, featureName, environmentName, projectId }) => {
const currentStrategy = useCurrentStrategy(
change,
projectId,
featureName,
environmentName
);
return (
<ChangeItemWrapper>
<ChangeItemInfo>
<Typography sx={theme => ({ color: theme.palette.error.main })}>
- Deleting strategy
</Typography>
{children}
</ChangeItemInfo>
{discard}
</ChangeItemWrapper>
<>
{change.action === 'addStrategy' && (
<>
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<Typography
color={
change.payload?.disabled
? 'action.disabled'
: 'success.dark'
}
>
+ Adding strategy:
</Typography>
<StrategyTooltipLink change={change}>
<StrategyDiff
change={change}
currentStrategy={currentStrategy}
/>
</StrategyTooltipLink>
<ConditionallyRender
condition={Boolean(
change.payload?.disabled === true
)}
show={<DisabledEnabledState disabled={true} />}
/>
</ChangeItemInfo>
{discard}
</ChangeItemCreateEditWrapper>
<StrategyExecution strategy={change.payload} />
</>
)}
{change.action === 'deleteStrategy' && (
<ChangeItemWrapper>
<ChangeItemInfo>
<Typography
sx={theme => ({ color: theme.palette.error.main })}
>
- Deleting strategy
</Typography>
{hasNameField(change.payload) && (
<StrategyTooltipLink change={change}>
<StrategyDiff
change={change}
currentStrategy={currentStrategy}
/>
</StrategyTooltipLink>
)}
</ChangeItemInfo>
{discard}
</ChangeItemWrapper>
)}
{change.action === 'updateStrategy' && (
<>
<ChangeItemCreateEditWrapper>
<ChangeItemInfo>
<EditHeader
wasDisabled={currentStrategy?.disabled}
willBeDisabled={change.payload?.disabled}
/>
<StrategyTooltipLink
change={change}
previousTitle={currentStrategy?.title}
>
<StrategyDiff
change={change}
currentStrategy={currentStrategy}
/>
</StrategyTooltipLink>
</ChangeItemInfo>
{discard}
</ChangeItemCreateEditWrapper>
<StrategyExecution strategy={change.payload} />
<ConditionallyRender
condition={
change.payload?.disabled !==
currentStrategy?.disabled
}
show={
<Typography
sx={{
marginTop: theme => theme.spacing(2),
paddingLeft: theme => theme.spacing(3),
paddingRight: theme => theme.spacing(3),
...flexRow,
gap: theme => theme.spacing(1),
}}
>
This strategy will be{' '}
<DisabledEnabledState
disabled={change.payload?.disabled || false}
/>
</Typography>
}
/>
</>
)}
</>
);
};

View File

@ -0,0 +1,25 @@
import {
IChangeRequestAddStrategy,
IChangeRequestDeleteStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
export const useCurrentStrategy = (
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy,
project: string,
feature: string,
environmentName: string
) => {
const currentFeature = useFeature(project, feature);
const currentStrategy = currentFeature.feature?.environments
.find(environment => environment.name === environmentName)
?.strategies.find(
strategy =>
'id' in change.payload && strategy.id === change.payload.id
);
return currentStrategy;
};

View File

@ -8,11 +8,13 @@ import {
formatStrategyName,
GetFeatureStrategyIcon,
} from 'utils/strategyNames';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import EventDiff from 'component/events/EventDiff/EventDiff';
import omit from 'lodash.omit';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { styled } from '@mui/material';
import { Typography, styled } from '@mui/material';
import { IFeatureStrategy } from 'interfaces/strategy';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { textTruncated } from 'themes/themeStyles';
const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto',
@ -25,41 +27,13 @@ const StyledCodeSection = styled('div')(({ theme }) => ({
},
}));
const useCurrentStrategy = (
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy,
project: string,
feature: string,
environmentName: string
) => {
const currentFeature = useFeature(project, feature);
const currentStrategy = currentFeature.feature?.environments
.find(environment => environment.name === environmentName)
?.strategies.find(
strategy =>
'id' in change.payload && strategy.id === change.payload.id
);
return currentStrategy;
};
export const StrategyDiff: FC<{
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
project: string;
feature: string;
environmentName: string;
}> = ({ change, project, feature, environmentName }) => {
const currentStrategy = useCurrentStrategy(
change,
project,
feature,
environmentName
);
currentStrategy?: IFeatureStrategy;
}> = ({ change, currentStrategy }) => {
const changeRequestStrategy =
change.action === 'deleteStrategy' ? undefined : change.payload;
@ -79,14 +53,35 @@ interface IStrategyTooltipLinkProps {
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
previousTitle?: string;
}
export const StrategyTooltipLink: FC<IStrategyTooltipLinkProps> = ({
change,
previousTitle,
children,
}) => (
<>
<GetFeatureStrategyIcon strategyName={change.payload.name} />
<ConditionallyRender
condition={Boolean(
previousTitle && previousTitle !== change.payload.title
)}
show={
<>
<Typography
component="s"
color="action.disabled"
sx={{
...textTruncated,
maxWidth: '100px',
}}
>
{previousTitle}
</Typography>{' '}
</>
}
/>
<TooltipLink
tooltip={children}
tooltipProps={{
@ -94,7 +89,20 @@ export const StrategyTooltipLink: FC<IStrategyTooltipLinkProps> = ({
maxHeight: 600,
}}
>
{formatStrategyName(change.payload.name)}
<Typography
component="span"
sx={{
...textTruncated,
maxWidth:
previousTitle === change.payload.title
? '300px'
: '200px',
display: 'block',
}}
>
{change.payload.title ||
formatStrategyName(change.payload.name)}
</Typography>
</TooltipLink>
</>
);

View File

@ -106,7 +106,7 @@ type ChangeRequestEnabled = { enabled: boolean };
type ChangeRequestAddStrategy = Pick<
IFeatureStrategy,
'parameters' | 'constraints' | 'segments'
'parameters' | 'constraints' | 'segments' | 'title' | 'disabled'
> & { name: string };
type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { id: string };
@ -114,6 +114,8 @@ type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { id: string };
type ChangeRequestDeleteStrategy = {
id: string;
name: string;
title?: string;
disabled?: boolean;
};
export type ChangeRequestAction =
@ -122,6 +124,3 @@ export type ChangeRequestAction =
| 'updateStrategy'
| 'deleteStrategy'
| 'patchVariant';
export const hasNameField = (payload: unknown): payload is { name: string } =>
typeof payload === 'object' && payload !== null && 'name' in payload;

View File

@ -9,7 +9,14 @@ import React, {
} from 'react';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
type Color =
| 'info'
| 'success'
| 'warning'
| 'error'
| 'secondary'
| 'neutral'
| 'disabled'; // TODO: refactor theme
interface IBadgeProps {
as?: React.ElementType;
@ -37,16 +44,27 @@ const StyledBadge = styled('div')<IBadgeProps>(
fontSize: theme.fontSizes.smallerBody,
fontWeight: theme.fontWeight.bold,
lineHeight: 1,
backgroundColor: theme.palette[color].light,
color: theme.palette[color].contrastText,
border: `1px solid ${theme.palette[color].border}`,
...(color === 'disabled'
? {
color: theme.palette.text.secondary,
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
}
: {
backgroundColor: theme.palette[color].light,
color: theme.palette[color].contrastText,
border: `1px solid ${theme.palette[color].border}`,
}),
})
);
const StyledBadgeIcon = styled('div')<IBadgeIconProps>(
({ theme, color = 'neutral', iconRight = false }) => ({
display: 'flex',
color: theme.palette[color].main,
color:
color === 'disabled'
? theme.palette.action.disabled
: theme.palette[color].main,
margin: iconRight
? theme.spacing(0, 0, 0, 0.5)
: theme.spacing(0, 0.5, 0, 0),

View File

@ -29,7 +29,7 @@ interface IConstraintAccordionViewProps {
const StyledAccordion = styled(Accordion)(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
backgroundColor: theme.palette.background.paper,
backgroundColor: 'transparent',
boxShadow: 'none',
margin: 0,
'&:before': {

View File

@ -1,6 +1,6 @@
import { DragEventHandler, FC, ReactNode } from 'react';
import { DragIndicator } from '@mui/icons-material';
import { styled, IconButton, Box } from '@mui/material';
import { styled, IconButton, Box, Chip } from '@mui/material';
import { IFeatureStrategy } from 'interfaces/strategy';
import {
getFeatureStrategyIcon,
@ -10,6 +10,7 @@ import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PlaygroundStrategySchema } from 'openapi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { Badge } from '../Badge/Badge';
interface IStrategyItemContainerProps {
strategy: IFeatureStrategy | PlaygroundStrategySchema;
@ -39,26 +40,35 @@ const StyledIndexLabel = styled('div')(({ theme }) => ({
},
}));
const StyledContainer = styled(Box)(({ theme }) => ({
const StyledContainer = styled(Box, {
shouldForwardProp: prop => prop !== 'disabled',
})<{ disabled?: boolean }>(({ theme, disabled }) => ({
borderRadius: theme.shape.borderRadiusMedium,
border: `1px solid ${theme.palette.divider}`,
'& + &': {
marginTop: theme.spacing(2),
},
background: theme.palette.background.paper,
background: disabled
? theme.palette.envAccordion.disabled
: theme.palette.background.paper,
}));
const StyledHeader = styled('div', {
shouldForwardProp: prop => prop !== 'draggable',
})(({ theme, draggable }) => ({
padding: theme.spacing(0.5, 2),
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
borderBottom: `1px solid ${theme.palette.divider}`,
fontWeight: theme.typography.fontWeightMedium,
paddingLeft: draggable ? theme.spacing(1) : theme.spacing(2),
}));
shouldForwardProp: prop => prop !== 'draggable' && prop !== 'disabled',
})<{ draggable: boolean; disabled: boolean }>(
({ theme, draggable, disabled }) => ({
padding: theme.spacing(0.5, 2),
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
borderBottom: `1px solid ${theme.palette.divider}`,
fontWeight: theme.typography.fontWeightMedium,
paddingLeft: draggable ? theme.spacing(1) : theme.spacing(2),
color: disabled
? theme.palette.action.disabled
: theme.palette.text.primary,
})
);
export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
strategy,
@ -78,8 +88,14 @@ export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
condition={orderNumber !== undefined}
show={<StyledIndexLabel>{orderNumber}</StyledIndexLabel>}
/>
<StyledContainer style={style}>
<StyledHeader draggable={Boolean(onDragStart)}>
<StyledContainer
disabled={strategy?.disabled || false}
style={style}
>
<StyledHeader
draggable={Boolean(onDragStart)}
disabled={Boolean(strategy?.disabled)}
>
<ConditionallyRender
condition={Boolean(onDragStart)}
show={() => (
@ -113,6 +129,14 @@ export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
: strategy.name
)}
/>
<ConditionallyRender
condition={Boolean(strategy?.disabled)}
show={() => (
<>
<Badge color="disabled">Disabled</Badge>
</>
)}
/>
<Box
sx={{
marginLeft: 'auto',

View File

@ -29,6 +29,52 @@ import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useCh
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
const useTitleTracking = () => {
const [previousTitle, setPreviousTitle] = useState<string>('');
const { trackEvent } = usePlausibleTracker();
const trackTitle = (title: string = '') => {
// don't expose the title, just if it was added, removed, or edited
if (title === previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'none',
on: 'edit',
},
});
}
if (previousTitle === '' && title !== '') {
trackEvent('strategyTitle', {
props: {
action: 'added',
on: 'edit',
},
});
}
if (previousTitle !== '' && title === '') {
trackEvent('strategyTitle', {
props: {
action: 'removed',
on: 'edit',
},
});
}
if (previousTitle !== '' && title !== '' && title !== previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'edited',
on: 'edit',
},
});
}
};
return {
setPreviousTitle,
trackTitle,
};
};
export const FeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
@ -48,7 +94,7 @@ export const FeatureStrategyEdit = () => {
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { trackEvent } = usePlausibleTracker();
const { setPreviousTitle, trackTitle } = useTitleTracking();
const { feature, refetchFeature } = useFeature(projectId, featureId);
@ -87,6 +133,7 @@ export const FeatureStrategyEdit = () => {
.flatMap(environment => environment.strategies)
.find(strategy => strategy.id === strategyId);
setStrategy(prev => ({ ...prev, ...savedStrategy }));
setPreviousTitle(savedStrategy?.title || '');
}, [strategyId, data]);
useEffect(() => {
@ -106,12 +153,10 @@ export const FeatureStrategyEdit = () => {
payload
);
trackEvent('strategyTitle', {
props: {
hasTitle: Boolean(strategy.title),
on: 'edit',
},
});
if (uiConfig?.flags?.strategyTitle) {
// NOTE: remove tracking when feature flag is removed
trackTitle(strategy.title);
}
await refetchSavedStrategySegments();
setToastData({
@ -202,6 +247,7 @@ export const createStrategyPayload = (
constraints: strategy.constraints ?? [],
parameters: strategy.parameters ?? {},
segments: segments.map(segment => segment.id),
disabled: strategy.disabled ?? false,
});
export const formatFeaturePath = (

View File

@ -0,0 +1,24 @@
import { FormControlLabel, Switch } from '@mui/material';
import { VFC } from 'react';
interface IFeatureStrategyEnabledDisabledProps {
enabled: boolean;
onToggleEnabled: () => void;
}
export const FeatureStrategyEnabledDisabled: VFC<
IFeatureStrategyEnabledDisabledProps
> = ({ enabled, onToggleEnabled }) => {
return (
<FormControlLabel
control={
<Switch
name="enabled"
onChange={onToggleEnabled}
checked={enabled}
/>
}
label="Enabled &ndash; This strategy will be used when evaluating feature toggles."
/>
);
};

View File

@ -30,6 +30,7 @@ import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewW
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
interface IFeatureStrategyFormProps {
feature: IFeatureToggle;
@ -250,6 +251,16 @@ export const FeatureStrategyForm = ({
hasAccess={access}
/>
<StyledHr />
<FeatureStrategyEnabledDisabled
enabled={!strategy?.disabled}
onToggleEnabled={() =>
setStrategy(strategyState => ({
...strategyState,
disabled: !strategyState.disabled,
}))
}
/>
<StyledHr />
<StyledButtons>
<PermissionButton
permission={permission}

View File

@ -0,0 +1,152 @@
import { VFC, useState } from 'react';
import { Alert } from '@mui/material';
import BlockIcon from '@mui/icons-material/Block';
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE_STRATEGY } from '@server/types/permissions';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { useEnableDisable } from './hooks/useEnableDisable';
import { useSuggestEnableDisable } from './hooks/useSuggestEnableDisable';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureStrategyChangeRequestAlert } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyChangeRequestAlert/FeatureStrategyChangeRequestAlert';
import { IDisableEnableStrategyProps } from './IDisableEnableStrategyProps';
const DisableStrategy: VFC<IDisableEnableStrategyProps> = ({ ...props }) => {
const { projectId, environmentId } = props;
const [isDialogueOpen, setDialogueOpen] = useState(false);
const { onDisable } = useEnableDisable({ ...props });
const { onSuggestDisable } = useSuggestEnableDisable({ ...props });
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const isChangeRequest = isChangeRequestConfigured(environmentId);
const onClick = (event: React.FormEvent) => {
event.preventDefault();
if (isChangeRequest) {
onSuggestDisable();
} else {
onDisable();
}
setDialogueOpen(false);
};
return (
<>
<PermissionIconButton
onClick={() => setDialogueOpen(true)}
projectId={projectId}
environmentId={environmentId}
permission={UPDATE_FEATURE_STRATEGY}
tooltipProps={{
title: 'Disable strategy',
}}
type="button"
>
<BlockIcon />
</PermissionIconButton>
<Dialogue
title={
isChangeRequest
? 'Add disabling strategy to change request?'
: 'Are you sure you want to disable this strategy?'
}
open={isDialogueOpen}
primaryButtonText={
isChangeRequest ? 'Add to draft' : 'Disable strategy'
}
secondaryButtonText="Cancel"
onClick={onClick}
onClose={() => setDialogueOpen(false)}
>
<ConditionallyRender
condition={isChangeRequest}
show={
<FeatureStrategyChangeRequestAlert
environment={environmentId}
/>
}
elseShow={
<Alert severity="error">
Disabling the strategy will change which users
receive access to the feature.
</Alert>
}
/>
</Dialogue>
</>
);
};
const EnableStrategy: VFC<IDisableEnableStrategyProps> = ({ ...props }) => {
const { projectId, environmentId } = props;
const [isDialogueOpen, setDialogueOpen] = useState(false);
const { onEnable } = useEnableDisable({ ...props });
const { onSuggestEnable } = useSuggestEnableDisable({ ...props });
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const isChangeRequest = isChangeRequestConfigured(environmentId);
const onClick = (event: React.FormEvent) => {
event.preventDefault();
if (isChangeRequest) {
onSuggestEnable();
} else {
onEnable();
}
setDialogueOpen(false);
};
return (
<>
<PermissionIconButton
onClick={() => setDialogueOpen(true)}
projectId={projectId}
environmentId={environmentId}
permission={UPDATE_FEATURE_STRATEGY}
tooltipProps={{
title: 'Enable strategy',
}}
type="button"
>
<TrackChangesIcon />
</PermissionIconButton>
<Dialogue
title={
isChangeRequest
? 'Add enabling strategy to change request?'
: 'Are you sure you want to enable this strategy?'
}
open={isDialogueOpen}
primaryButtonText={
isChangeRequest ? 'Add to draft' : 'Enable strategy'
}
secondaryButtonText="Cancel"
onClick={onClick}
onClose={() => setDialogueOpen(false)}
>
<ConditionallyRender
condition={isChangeRequest}
show={
<FeatureStrategyChangeRequestAlert
environment={environmentId}
/>
}
elseShow={
<Alert severity="error">
Enabling the strategy will change which users
receive access to the feature.
</Alert>
}
/>
</Dialogue>
</>
);
};
export const DisableEnableStrategy: VFC<IDisableEnableStrategyProps> = ({
...props
}) =>
props.strategy.disabled ? (
<EnableStrategy {...props} />
) : (
<DisableStrategy {...props} />
);

View File

@ -0,0 +1,8 @@
import { IFeatureStrategy } from 'interfaces/strategy';
export interface IDisableEnableStrategyProps {
projectId: string;
featureId: string;
environmentId: string;
strategy: IFeatureStrategy;
}

View File

@ -0,0 +1,41 @@
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { IDisableEnableStrategyProps } from '../IDisableEnableStrategyProps';
export const useEnableDisable = ({
projectId,
environmentId,
featureId,
strategy,
}: IDisableEnableStrategyProps) => {
const { refetchFeature } = useFeature(projectId, featureId);
const { setStrategyDisabledState } = useFeatureStrategyApi();
const { setToastData, setToastApiError } = useToast();
const onEnableDisable = (enabled: boolean) => async () => {
try {
await setStrategyDisabledState(
projectId,
featureId,
environmentId,
strategy.id,
!enabled
);
setToastData({
title: `Strategy ${enabled ? 'enabled' : 'disabled'}`,
type: 'success',
});
refetchFeature();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return {
onDisable: onEnableDisable(false),
onEnable: onEnableDisable(true),
};
};

View File

@ -0,0 +1,40 @@
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { IDisableEnableStrategyProps } from '../IDisableEnableStrategyProps';
export const useSuggestEnableDisable = ({
projectId,
environmentId,
featureId,
strategy,
}: IDisableEnableStrategyProps) => {
const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { setToastData, setToastApiError } = useToast();
const onSuggestEnableDisable = (enabled: boolean) => async () => {
try {
await addChange(projectId, environmentId, {
action: 'updateStrategy',
feature: featureId,
payload: {
...strategy,
disabled: !enabled,
},
});
setToastData({
title: 'Changes added to the draft!',
type: 'success',
});
await refetchChangeRequests();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return {
onSuggestDisable: onSuggestEnableDisable(false),
onSuggestEnable: onSuggestEnableDisable(true),
};
};

View File

@ -12,6 +12,8 @@ import { StrategyExecution } from './StrategyExecution/StrategyExecution';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { CopyStrategyIconMenu } from './CopyStrategyIconMenu/CopyStrategyIconMenu';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
import { DisableEnableStrategy } from './DisableEnableStrategy/DisableEnableStrategy';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface IStrategyItemProps {
environmentId: string;
@ -32,6 +34,7 @@ export const StrategyItem: FC<IStrategyItemProps> = ({
orderNumber,
headerChildren,
}) => {
const { uiConfig } = useUiConfig();
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
@ -76,6 +79,17 @@ export const StrategyItem: FC<IStrategyItemProps> = ({
>
<Edit />
</PermissionIconButton>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.strategyDisable)}
show={() => (
<DisableEnableStrategy
projectId={projectId}
featureId={featureId}
environmentId={environmentId}
strategy={strategy}
/>
)}
/>
<FeatureStrategyRemove
projectId={projectId}
featureId={featureId}

View File

@ -111,7 +111,7 @@ export const ChangeRequestTable: VFC = () => {
return {
key,
label: `${key} ${labelText}`,
sx: { 'font-size': theme.fontSizes.smallBody },
sx: { fontSize: theme.fontSizes.smallBody },
};
});

View File

@ -71,11 +71,37 @@ const useFeatureStrategyApi = () => {
await makeRequest(req.caller, req.id);
};
const setStrategyDisabledState = async (
projectId: string,
featureId: string,
environmentId: string,
strategyId: string,
disabled: boolean
): Promise<void> => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
const req = createRequest(
path,
{
method: 'PATCH',
body: JSON.stringify([
{
path: '/disabled',
value: disabled,
op: 'replace',
},
]),
},
'setStrategyDisabledState'
);
await makeRequest(req.caller, req.id);
};
return {
addStrategyToFeature,
updateStrategyOnFeature,
deleteStrategyFromFeature,
setStrategiesSortOrder,
setStrategyDisabledState,
loading,
errors,
};

View File

@ -11,6 +11,7 @@ export interface IFeatureStrategy {
projectId?: string;
environment?: string;
segments?: number[];
disabled?: boolean;
}
export interface IFeatureStrategyParameters {
@ -24,6 +25,7 @@ export interface IFeatureStrategyPayload {
constraints: IConstraint[];
parameters: IFeatureStrategyParameters;
segments?: number[];
disabled?: boolean;
}
export interface IStrategy {

View File

@ -51,6 +51,7 @@ export interface IFlags {
demo?: boolean;
strategyTitle?: boolean;
groupRootRoles?: boolean;
strategyDisable?: boolean;
googleAuthEnabled?: boolean;
}

View File

@ -11,6 +11,8 @@ export interface CreateFeatureStrategySchema {
name: string;
/** A descriptive title for the strategy */
title?: string | null;
/** A toggle to disable the strategy. defaults to false. Disabled strategies are not evaluated or returned to the SDKs */
disabled?: boolean | null;
/** The order of the strategy in the list */
sortOrder?: number;
/** A list of the constraints attached to the strategy */

View File

@ -16,6 +16,8 @@ export interface FeatureStrategySchema {
name: string;
/** A descriptive title for the strategy */
title?: string | null;
/** A toggle to disable the strategy. defaults to false. Disabled strategies are not evaluated or returned to the SDKs */
disabled?: boolean | null;
/** The name or feature the strategy is attached to */
featureName?: string;
/** The order of the strategy in the list */

View File

@ -10,6 +10,8 @@ export interface GroupSchema {
name: string;
description?: string | null;
mappingsSSO?: string[];
/** A role id that is used as the root role for all users in this group. This can be either the id of the Editor or Admin role. */
rootRole?: number | null;
createdBy?: string | null;
createdAt?: string | null;
users?: GroupUserModelSchema[];

View File

@ -17,6 +17,8 @@ export interface PlaygroundStrategySchema {
id: string;
/** The strategy's evaluation result. If the strategy is a custom strategy that Unleash can't evaluate, `evaluationStatus` will be `unknown`. Otherwise, it will be `true` or `false` */
result: PlaygroundStrategySchemaResult;
/** The strategy's status. Disabled strategies are not evaluated */
disabled: boolean | null;
/** The strategy's segments and their evaluation results. */
segments: PlaygroundSegmentSchema[];
/** The strategy's constraints and their evaluation results. */

View File

@ -15,6 +15,10 @@ import type { EnvironmentSchema } from './environmentSchema';
import type { SegmentSchema } from './segmentSchema';
import type { FeatureStrategySegmentSchema } from './featureStrategySegmentSchema';
/**
* The state of the application used by export/import APIs which are deprecated in favor of the more fine grained /api/admin/export and /api/admin/import APIs
* @deprecated
*/
export interface StateSchema {
version: number;
features?: FeatureSchema[];

View File

@ -10,5 +10,9 @@ export interface UpdateFeatureStrategySchema {
name?: string;
sortOrder?: number;
constraints?: ConstraintSchema[];
/** A descriptive title for the strategy */
title?: string | null;
/** A toggle to disable the strategy. defaults to true. Disabled strategies are not evaluated or returned to the SDKs */
disabled?: boolean | null;
parameters?: ParametersSchema;
}

View File

@ -87,6 +87,7 @@ exports[`should create default config 1`] = `
"proPlanAutoCharge": false,
"projectScopedStickiness": false,
"responseTimeWithAppNameKillSwitch": false,
"strategyDisable": false,
"strategyTitle": false,
"strictSchemaValidation": false,
},
@ -114,6 +115,7 @@ exports[`should create default config 1`] = `
"proPlanAutoCharge": false,
"projectScopedStickiness": false,
"responseTimeWithAppNameKillSwitch": false,
"strategyDisable": false,
"strategyTitle": false,
"strictSchemaValidation": false,
},

View File

@ -80,6 +80,10 @@ const flags = {
process.env.UNLEASH_STRATEGY_TITLE,
false,
),
strategyDisable: parseEnvVarBoolean(
process.env.UNLEASH_STRATEGY_DISABLE,
false,
),
googleAuthEnabled: parseEnvVarBoolean(
process.env.GOOGLE_AUTH_ENABLED,
false,