From 3bb09c5ce4095321702fd6c3a346d3ab84b3a99d Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Wed, 26 Apr 2023 11:41:24 +0200 Subject: [PATCH] Disable and enable strategies - frontend (#3582) Signed-off-by: andreas-unleash Co-authored-by: andreas-unleash --- .../ChangeRequest/Changes/Change/Change.tsx | 69 +----- .../Changes/Change/StrategyChange.tsx | 228 ++++++++++++++---- .../Change/hooks/useCurrentStrategy.ts | 25 ++ .../StrategyTooltipLink.tsx | 74 +++--- .../changeRequest/changeRequest.types.ts | 7 +- frontend/src/component/common/Badge/Badge.tsx | 28 ++- .../ConstraintAccordionView.tsx | 2 +- .../StrategyItemContainer.tsx | 54 +++-- .../FeatureStrategyEdit.tsx | 60 ++++- .../FeatureStrategyEnabledDisabled.tsx | 24 ++ .../FeatureStrategyForm.tsx | 11 + .../DisableEnableStrategy.tsx | 152 ++++++++++++ .../IDisableEnableStrategyProps.ts | 8 + .../hooks/useEnableDisable.ts | 41 ++++ .../hooks/useSuggestEnableDisable.ts | 40 +++ .../StrategyItem/StrategyItem.tsx | 14 ++ .../ChangeRequestTable.tsx | 2 +- .../useFeatureStrategyApi.ts | 26 ++ frontend/src/interfaces/strategy.ts | 2 + frontend/src/interfaces/uiConfig.ts | 1 + .../models/createFeatureStrategySchema.ts | 2 + .../openapi/models/featureStrategySchema.ts | 2 + frontend/src/openapi/models/groupSchema.ts | 2 + .../models/playgroundStrategySchema.ts | 2 + frontend/src/openapi/models/stateSchema.ts | 4 + .../models/updateFeatureStrategySchema.ts | 4 + .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/types/experimental.ts | 4 + 28 files changed, 722 insertions(+), 168 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/hooks/useCurrentStrategy.ts create mode 100644 frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/DisableEnableStrategy.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/IDisableEnableStrategyProps.ts create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useEnableDisable.ts create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useSuggestEnableDisable.ts diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Change.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Change.tsx index 2de63edfdc3..850bcefee86 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Change.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Change.tsx @@ -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 ( )} - {change.action === 'addStrategy' && ( - <> - - - - - - - - )} - {change.action === 'deleteStrategy' && ( - - {hasNameField(change.payload) && ( - - - - )} - - )} - {change.action === 'updateStrategy' && ( - <> - - - - - - - - )} + {change.action === 'addStrategy' || + change.action === 'deleteStrategy' || + change.action === 'updateStrategy' ? ( + + ) : null} {change.action === 'patchVariant' && ( ({ +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 ( + + }> + Disabled + + + ); + } + return ( - - - ({ - color: theme.palette.success.dark, - })} - > - + Adding strategy: - - {children} - - {discard} - + + }> + Enabled + + ); }; -export const StrategyEditedChange: FC<{ discard?: ReactNode }> = ({ - children, - discard, -}) => { - return ( - - - Editing strategy: - {children} - - {discard} - - ); +const EditHeader: VFC<{ + wasDisabled?: boolean; + willBeDisabled?: boolean; +}> = ({ wasDisabled = false, willBeDisabled = false }) => { + if (wasDisabled && willBeDisabled) { + return ( + + Editing disabled strategy + + ); + } + + if (!wasDisabled && willBeDisabled) { + return Editing strategy; + } + + if (wasDisabled && !willBeDisabled) { + return Editing strategy; + } + + return Editing strategy:; }; -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 ( - - - ({ color: theme.palette.error.main })}> - - Deleting strategy - - {children} - - {discard} - + <> + {change.action === 'addStrategy' && ( + <> + + + + + Adding strategy: + + + + + } + /> + + {discard} + + + + )} + {change.action === 'deleteStrategy' && ( + + + ({ color: theme.palette.error.main })} + > + - Deleting strategy + + {hasNameField(change.payload) && ( + + + + )} + + {discard} + + )} + {change.action === 'updateStrategy' && ( + <> + + + + + + + + {discard} + + + theme.spacing(2), + paddingLeft: theme => theme.spacing(3), + paddingRight: theme => theme.spacing(3), + ...flexRow, + gap: theme => theme.spacing(1), + }} + > + This strategy will be{' '} + + + } + /> + + )} + ); }; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/hooks/useCurrentStrategy.ts b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/hooks/useCurrentStrategy.ts new file mode 100644 index 00000000000..bfb389c9016 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/hooks/useCurrentStrategy.ts @@ -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; +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx index abd73211f98..5c162d7a47a 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx @@ -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 = ({ change, + previousTitle, children, }) => ( <> + + + {previousTitle} + {' '} + + } + /> = ({ maxHeight: 600, }} > - {formatStrategyName(change.payload.name)} + + {change.payload.title || + formatStrategyName(change.payload.name)} + ); diff --git a/frontend/src/component/changeRequest/changeRequest.types.ts b/frontend/src/component/changeRequest/changeRequest.types.ts index 0592725028b..50d7779eab7 100644 --- a/frontend/src/component/changeRequest/changeRequest.types.ts +++ b/frontend/src/component/changeRequest/changeRequest.types.ts @@ -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; diff --git a/frontend/src/component/common/Badge/Badge.tsx b/frontend/src/component/common/Badge/Badge.tsx index a6a16a9da28..5cceb30c461 100644 --- a/frontend/src/component/common/Badge/Badge.tsx +++ b/frontend/src/component/common/Badge/Badge.tsx @@ -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')( 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')( ({ 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), diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx index 0c2426021bf..e08bf2eae88 100644 --- a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx +++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx @@ -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': { diff --git a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx index 280e2e8f719..749c0bcba4c 100644 --- a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx +++ b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx @@ -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 = ({ strategy, @@ -78,8 +88,14 @@ export const StrategyItemContainer: FC = ({ condition={orderNumber !== undefined} show={{orderNumber}} /> - - + + ( @@ -113,6 +129,14 @@ export const StrategyItemContainer: FC = ({ : strategy.name )} /> + ( + <> + Disabled + + )} + /> { + const [previousTitle, setPreviousTitle] = useState(''); + 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 = ( diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled.tsx new file mode 100644 index 00000000000..7eeefca2489 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled.tsx @@ -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 ( + + } + label="Enabled – This strategy will be used when evaluating feature toggles." + /> + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx index 5d0a7fab1a4..96de854ad9f 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx @@ -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} /> + + setStrategy(strategyState => ({ + ...strategyState, + disabled: !strategyState.disabled, + })) + } + /> + = ({ ...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 ( + <> + setDialogueOpen(true)} + projectId={projectId} + environmentId={environmentId} + permission={UPDATE_FEATURE_STRATEGY} + tooltipProps={{ + title: 'Disable strategy', + }} + type="button" + > + + + setDialogueOpen(false)} + > + + } + elseShow={ + + Disabling the strategy will change which users + receive access to the feature. + + } + /> + + + ); +}; + +const EnableStrategy: VFC = ({ ...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 ( + <> + setDialogueOpen(true)} + projectId={projectId} + environmentId={environmentId} + permission={UPDATE_FEATURE_STRATEGY} + tooltipProps={{ + title: 'Enable strategy', + }} + type="button" + > + + + setDialogueOpen(false)} + > + + } + elseShow={ + + Enabling the strategy will change which users + receive access to the feature. + + } + /> + + + ); +}; + +export const DisableEnableStrategy: VFC = ({ + ...props +}) => + props.strategy.disabled ? ( + + ) : ( + + ); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/IDisableEnableStrategyProps.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/IDisableEnableStrategyProps.ts new file mode 100644 index 00000000000..da21c953f41 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/IDisableEnableStrategyProps.ts @@ -0,0 +1,8 @@ +import { IFeatureStrategy } from 'interfaces/strategy'; + +export interface IDisableEnableStrategyProps { + projectId: string; + featureId: string; + environmentId: string; + strategy: IFeatureStrategy; +} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useEnableDisable.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useEnableDisable.ts new file mode 100644 index 00000000000..aa6b9f55e23 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useEnableDisable.ts @@ -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), + }; +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useSuggestEnableDisable.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useSuggestEnableDisable.ts new file mode 100644 index 00000000000..5e6738a5138 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/DisableEnableStrategy/hooks/useSuggestEnableDisable.ts @@ -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), + }; +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx index d7dea23e63d..a4cbb30a65a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx @@ -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 = ({ orderNumber, headerChildren, }) => { + const { uiConfig } = useUiConfig(); const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); @@ -76,6 +79,17 @@ export const StrategyItem: FC = ({ > + ( + + )} + /> { return { key, label: `${key} ${labelText}`, - sx: { 'font-size': theme.fontSizes.smallBody }, + sx: { fontSize: theme.fontSizes.smallBody }, }; }); diff --git a/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts b/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts index f22a3a32201..03cff34862a 100644 --- a/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.ts @@ -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 => { + 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, }; diff --git a/frontend/src/interfaces/strategy.ts b/frontend/src/interfaces/strategy.ts index 08b828263fc..3109783efe6 100644 --- a/frontend/src/interfaces/strategy.ts +++ b/frontend/src/interfaces/strategy.ts @@ -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 { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index e1c21447f82..e4c534557db 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -51,6 +51,7 @@ export interface IFlags { demo?: boolean; strategyTitle?: boolean; groupRootRoles?: boolean; + strategyDisable?: boolean; googleAuthEnabled?: boolean; } diff --git a/frontend/src/openapi/models/createFeatureStrategySchema.ts b/frontend/src/openapi/models/createFeatureStrategySchema.ts index 62673c4779b..4eec944145f 100644 --- a/frontend/src/openapi/models/createFeatureStrategySchema.ts +++ b/frontend/src/openapi/models/createFeatureStrategySchema.ts @@ -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 */ diff --git a/frontend/src/openapi/models/featureStrategySchema.ts b/frontend/src/openapi/models/featureStrategySchema.ts index e806d49854c..ff1b62bddba 100644 --- a/frontend/src/openapi/models/featureStrategySchema.ts +++ b/frontend/src/openapi/models/featureStrategySchema.ts @@ -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 */ diff --git a/frontend/src/openapi/models/groupSchema.ts b/frontend/src/openapi/models/groupSchema.ts index df9f7f4755e..af576ae26ac 100644 --- a/frontend/src/openapi/models/groupSchema.ts +++ b/frontend/src/openapi/models/groupSchema.ts @@ -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[]; diff --git a/frontend/src/openapi/models/playgroundStrategySchema.ts b/frontend/src/openapi/models/playgroundStrategySchema.ts index 1a46fe14db1..ea0bcb607fa 100644 --- a/frontend/src/openapi/models/playgroundStrategySchema.ts +++ b/frontend/src/openapi/models/playgroundStrategySchema.ts @@ -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. */ diff --git a/frontend/src/openapi/models/stateSchema.ts b/frontend/src/openapi/models/stateSchema.ts index fa6cc453e93..cb2735b1008 100644 --- a/frontend/src/openapi/models/stateSchema.ts +++ b/frontend/src/openapi/models/stateSchema.ts @@ -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[]; diff --git a/frontend/src/openapi/models/updateFeatureStrategySchema.ts b/frontend/src/openapi/models/updateFeatureStrategySchema.ts index 670eb4881e5..fb8a52c76cc 100644 --- a/frontend/src/openapi/models/updateFeatureStrategySchema.ts +++ b/frontend/src/openapi/models/updateFeatureStrategySchema.ts @@ -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; } diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 9801dd9fcec..050279a6bf2 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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, }, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 14431a23574..509712cf146 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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,