1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-19 17:52:45 +02:00

refactor: remove flagOverviewRedesign flag (#9888)

Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
Tymoteusz Czech 2025-05-06 10:25:57 +02:00 committed by GitHub
parent 6a2953f768
commit af93f93836
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 172 additions and 5414 deletions

View File

@ -40,7 +40,6 @@ describe('demo', () => {
res.body.flags = {
...res.body.flags,
demo: true,
flagOverviewRedesign: true,
};
}
});

View File

@ -44,7 +44,6 @@ describe('feature', () => {
if (res.body) {
res.body.flags = {
...res.body.flags,
flagOverviewRedesign: true,
};
}
});

View File

@ -1,6 +1,6 @@
import type React from 'react';
import type { FC } from 'react';
import { render, screen, within, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider } from 'themes/ThemeProvider';
import { MainLayout } from 'component/layout/MainLayout/MainLayout';
@ -271,13 +271,6 @@ const verifyBannerForPendingChangeRequest = async () => {
return screen.findByText('Change request mode', {}, { timeout: 5000 });
};
const changeFlag = async (environment: string) => {
const featureFlagStatusBox = screen.getByTestId('feature-flag-status');
await within(featureFlagStatusBox).findByText(environment);
const flag = screen.getAllByRole('checkbox')[1];
fireEvent.click(flag);
};
const verifyChangeRequestDialog = async (bannerMainText: string) => {
await screen.findByText('Your suggestion:');
const message = screen.getByTestId('update-enabled-message').textContent;
@ -298,7 +291,8 @@ test('add flag change to pending change request', async () => {
await verifyBannerForPendingChangeRequest();
await changeFlag('production');
const flag = screen.getByLabelText('production');
fireEvent.click(flag);
await verifyChangeRequestDialog('Enable feature flag test in production');
}, 10000);

View File

@ -1,4 +1,4 @@
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
@ -226,7 +226,7 @@ test('Display default disable feature', async () => {
expect(screen.getByText('Feature status will change')).toBeInTheDocument();
});
test('Displays feature strategy variants table when addStrategy action with variants', async () => {
test('Displays strategy variant table when addStrategy action with variants', async () => {
render(
<Routes>
<Route
@ -269,7 +269,7 @@ test('Displays feature strategy variants table when addStrategy action with vari
},
);
await screen.findByText('Setting strategy variants to:');
await screen.findByText('Adding strategy variants:');
});
test('Displays feature strategy variants table when there is a change in the variants array', async () => {
@ -297,7 +297,10 @@ test('Displays feature strategy variants table when there is a change in the var
route: '/projects/default/change-requests/27',
},
);
await screen.findByText('Updating strategy variants to:');
waitFor(async () => {
await screen.findByText('Updating strategy variants to:');
});
});
test('Displays feature strategy variants table when existing strategy does not have variants and change does', async () => {
@ -325,5 +328,5 @@ test('Displays feature strategy variants table when existing strategy does not h
route: '/projects/default/change-requests/27',
},
);
await screen.findByText('Updating strategy variants to:');
await screen.findByText('Adding strategy variants:');
});

View File

@ -31,6 +31,7 @@ const StyledSingleChangeBox = styled(Box, {
$isAfterWarning,
$isLast,
}) => ({
overflow: 'hidden',
borderLeft: '1px solid',
borderRight: '1px solid',
borderTop: '1px solid',

View File

@ -12,7 +12,7 @@ import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePla
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import EventDiff from 'component/events/EventDiff/EventDiff';
import { ReleasePlan } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan';
import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/LegacyReleasePlanMilestone';
import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone';
import type { IReleasePlan } from 'interfaces/releasePlans';
export const ChangeItemWrapper = styled(Box)({

View File

@ -119,7 +119,8 @@ test('Editing strategy before change request is applied diffs against current st
await screen.findByText('+ variants.0.name: "change_variant"');
await screen.findByText('Updating strategy variants to:');
await screen.findByText('change_variant');
const variants = await screen.findAllByText('change_variant');
expect(variants).toHaveLength(2);
});
test('Editing strategy after change request is applied diffs against the snapshot', async () => {
@ -189,7 +190,8 @@ test('Editing strategy after change request is applied diffs against the snapsho
await screen.findByText('+ variants.0.name: "change_variant"');
await screen.findByText('Updating strategy variants to:');
await screen.findByText('change_variant');
const variants = await screen.findAllByText('change_variant');
expect(variants).toHaveLength(2);
});
test('Deleting strategy before change request is applied diffs against current strategy', async () => {
@ -226,7 +228,6 @@ test('Deleting strategy before change request is applied diffs against current s
await userEvent.hover(viewDiff);
await screen.findByText('- constraints (deleted)');
await screen.findByText('Deleting strategy variants:');
await screen.findByText('current_variant');
});
@ -281,7 +282,6 @@ test('Deleting strategy after change request is applied diffs against the snapsh
await userEvent.hover(viewDiff);
await screen.findByText('- constraints (deleted)');
await screen.findByText('Deleting strategy variants:');
await screen.findByText('snapshot_variant');
});
@ -329,9 +329,8 @@ test('Adding strategy always diffs against undefined strategy', async () => {
const viewDiff = await screen.findByText('View Diff');
await userEvent.hover(viewDiff);
await screen.findByText(`+ name: "flexibleRollout"`);
await screen.findByText('Setting strategy variants to:');
await screen.findByText('change_variant');
const variants = await screen.findAllByText('change_variant');
expect(variants).toHaveLength(2);
});
test('Segments order does not matter for diff calculation', async () => {

View File

@ -21,7 +21,6 @@ import { flexRow } from 'themes/themeStyles';
import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable';
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { useUiFlag } from 'hooks/useUiFlag';
export const ChangeItemWrapper = styled(Box)({
display: 'flex',
@ -163,21 +162,6 @@ const DeleteStrategy: FC<{
{referenceStrategy && (
<StrategyExecution strategy={referenceStrategy} />
)}
<ConditionallyRender
condition={Boolean(referenceStrategy?.variants?.length)}
show={
referenceStrategy?.variants && (
<StyledBox>
<StyledTypography>
Deleting strategy variants:
</StyledTypography>
<EnvironmentVariantsTable
variants={referenceStrategy.variants}
/>
</StyledBox>
)
}
/>
</>
);
};
@ -251,19 +235,26 @@ const UpdateStrategy: FC<{
}
/>
<StrategyExecution strategy={change.payload} />
<ConditionallyRender
condition={Boolean(hasVariantDiff)}
show={
<StyledBox>
{hasVariantDiff ? (
<StyledBox>
{change.payload.variants?.length ? (
<>
<StyledTypography>
{currentStrategy?.variants?.length
? 'Updating strategy variants to:'
: 'Adding strategy variants:'}
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</>
) : (
<StyledTypography>
Updating strategy variants to:
Removed all strategy variants.
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</StyledBox>
}
/>
)}
</StyledBox>
) : null}
</>
);
};
@ -271,55 +262,45 @@ const UpdateStrategy: FC<{
const AddStrategy: FC<{
change: IChangeRequestAddStrategy;
actions?: ReactNode;
}> = ({ change, actions }) => {
const showOldStrategyVariants = !useUiFlag('flagOverviewRedesign');
return (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography
color={
change.payload?.disabled
? 'action.disabled'
: 'success.dark'
}
>
+ Adding strategy:
</Typography>
<StrategyTooltipLink
name={change.payload.name}
title={change.payload.title}
>
<StrategyDiff
change={change}
currentStrategy={undefined}
/>
</StrategyTooltipLink>
<div>
<DisabledEnabledState
disabled
show={change.payload?.disabled === true}
/>
</div>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<StrategyExecution strategy={change.payload} />
{showOldStrategyVariants &&
change.payload.variants &&
change.payload.variants.length > 0 && (
<StyledBox>
<StyledTypography>
Setting strategy variants to:
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants}
/>
</StyledBox>
)}
</>
);
};
}> = ({ change, actions }) => (
<>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<Typography
color={
change.payload?.disabled
? 'action.disabled'
: 'success.dark'
}
>
+ Adding strategy:
</Typography>
<StrategyTooltipLink
name={change.payload.name}
title={change.payload.title}
>
<StrategyDiff change={change} currentStrategy={undefined} />
</StrategyTooltipLink>
<div>
<DisabledEnabledState
disabled
show={change.payload?.disabled === true}
/>
</div>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<StrategyExecution strategy={change.payload} />
{change.payload.variants?.length ? (
<StyledBox>
<StyledTypography>Adding strategy variants:</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</StyledBox>
) : null}
</>
);
export const StrategyChange: FC<{
actions?: ReactNode;

View File

@ -287,7 +287,10 @@ const copyButtonsActiveInOtherEnv = async () => {
const openEnvironments = async (envNames: string[]) => {
for (const env of envNames) {
(await screen.findAllByText(env))[1].click();
const environmentHeader = await screen.findByRole('heading', {
name: env,
});
fireEvent.click(environmentHeader);
}
};
@ -313,7 +316,6 @@ test('open mode + non-project member can perform basic change request actions',
<FeatureView />
</UnleashUiSetup>,
);
await openEnvironments(['development', 'production', 'custom']);
await strategiesAreDisplayed('UserIDs', 'Standard');

View File

@ -18,14 +18,12 @@ import {
StyledFlexAlignCenterBox,
StyledSuccessIcon,
} from '../ChangeRequestSidebar';
import CloudCircle from '@mui/icons-material/CloudCircle';
import { AddCommentField } from '../../ChangeRequestOverview/ChangeRequestComments/AddCommentField';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import Input from 'component/common/Input/Input';
import { ChangeRequestTitle } from './ChangeRequestTitle';
import { UpdateCount } from 'component/changeRequest/UpdateCount';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useUiFlag } from 'hooks/useUiFlag';
const SubmitChangeRequestButton: FC<{
onClick: () => void;
@ -70,7 +68,6 @@ export const EnvironmentChangeRequest: FC<{
children?: React.ReactNode;
}> = ({ environmentChangeRequest, onClose, onReview, onDiscard, children }) => {
const theme = useTheme();
const showCloudIcon = !useUiFlag('flagOverviewRedesign');
const [commentText, setCommentText] = useState('');
const { user } = useAuthUser();
const [title, setTitle] = useState(environmentChangeRequest.title);
@ -98,14 +95,6 @@ export const EnvironmentChangeRequest: FC<{
alignItems: 'center',
}}
>
{showCloudIcon ? (
<CloudCircle
sx={(theme) => ({
color: theme.palette.primary.light,
mr: 0.5,
})}
/>
) : null}
<Typography component='span' variant='h2'>
{environmentChangeRequest.environment}
</Typography>

View File

@ -1,10 +1,7 @@
import { ConstraintIcon } from 'component/common/LegacyConstraintAccordion/ConstraintIcon';
import type { IConstraint } from 'interfaces/strategy';
import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo';
import { ConstraintAccordionViewHeaderInfo as LegacyConstraintAccordionViewHeaderInfo } from './LegacyConstraintAccordionViewHeaderInfo';
import { styled } from '@mui/system';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { useUiFlag } from 'hooks/useUiFlag';
import { ConstraintAccordionViewActions } from '../../ConstraintAccordionViewActions/ConstraintAccordionViewActions';
import { ConstraintAccordionEditActions } from '../../ConstraintAccordionEditActions/ConstraintAccordionEditActions';
@ -13,9 +10,15 @@ interface IConstraintAccordionViewHeaderProps {
onDelete?: () => void;
onEdit?: () => void;
onUse?: () => void;
/**
* @deprecated
*/
singleValue: boolean;
expanded: boolean;
allowExpand: (shouldExpand: boolean) => void;
/**
* @deprecated
*/
compact?: boolean;
disabled?: boolean;
}
@ -43,7 +46,6 @@ export const ConstraintAccordionViewHeader = ({
disabled,
}: IConstraintAccordionViewHeaderProps) => {
const { context } = useUnleashContext();
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const { contextName } = constraint;
const disableEdit = !context
@ -52,25 +54,12 @@ export const ConstraintAccordionViewHeader = ({
return (
<StyledContainer>
{!flagOverviewRedesign ? (
<ConstraintIcon compact={compact} disabled={disabled} />
) : null}
{flagOverviewRedesign ? (
<ConstraintAccordionViewHeaderInfo
constraint={constraint}
allowExpand={allowExpand}
expanded={expanded}
disabled={disabled}
/>
) : (
<LegacyConstraintAccordionViewHeaderInfo
constraint={constraint}
singleValue={singleValue}
allowExpand={allowExpand}
expanded={expanded}
disabled={disabled}
/>
)}
<ConstraintAccordionViewHeaderInfo
constraint={constraint}
allowExpand={allowExpand}
expanded={expanded}
disabled={disabled}
/>
{onUse ? (
<ConstraintAccordionViewActions onUse={onUse} />
) : (

View File

@ -1,104 +0,0 @@
import { styled, Tooltip } from '@mui/material';
import { ConstraintViewHeaderOperator } from './ConstraintViewHeaderOperator';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ConstraintAccordionViewHeaderSingleValue } from './ConstraintAccordionViewHeaderSingleValue';
import { ConstraintAccordionViewHeaderMultipleValues } from './ConstraintAccordionViewHeaderMultipleValues';
import type { IConstraint } from 'interfaces/strategy';
const StyledHeaderText = styled('span')(({ theme }) => ({
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
maxWidth: '100px',
minWidth: '100px',
marginRight: '10px',
marginTop: 'auto',
marginBottom: 'auto',
wordBreak: 'break-word',
fontSize: theme.fontSizes.smallBody,
[theme.breakpoints.down(710)]: {
textAlign: 'center',
padding: theme.spacing(1, 0),
marginRight: 'inherit',
maxWidth: 'inherit',
},
}));
const StyledHeaderWrapper = styled('div')(({ theme }) => ({
display: 'flex',
width: '100%',
justifyContent: 'space-between',
borderRadius: theme.spacing(1),
}));
const StyledHeaderMetaInfo = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'stretch',
marginLeft: theme.spacing(1),
[theme.breakpoints.down('sm')]: {
marginLeft: 0,
flexDirection: 'column',
alignItems: 'center',
width: '100%',
},
}));
interface ConstraintAccordionViewHeaderMetaInfoProps {
constraint: IConstraint;
singleValue: boolean;
expanded: boolean;
allowExpand: (shouldExpand: boolean) => void;
disabled?: boolean;
maxLength?: number;
}
export const ConstraintAccordionViewHeaderInfo = ({
constraint,
singleValue,
allowExpand,
expanded,
disabled = false,
maxLength = 112, //The max number of characters in the values text for NOT allowing expansion
}: ConstraintAccordionViewHeaderMetaInfoProps) => {
return (
<StyledHeaderWrapper>
<StyledHeaderMetaInfo>
<Tooltip title={constraint.contextName} arrow>
<StyledHeaderText
sx={(theme) => ({
color: disabled
? theme.palette.text.secondary
: 'inherit',
})}
>
{constraint.contextName}
</StyledHeaderText>
</Tooltip>
<ConstraintViewHeaderOperator
constraint={constraint}
disabled={disabled}
/>
<ConditionallyRender
condition={singleValue}
show={
<ConstraintAccordionViewHeaderSingleValue
constraint={constraint}
allowExpand={allowExpand}
disabled={disabled}
/>
}
elseShow={
<ConstraintAccordionViewHeaderMultipleValues
constraint={constraint}
expanded={expanded}
allowExpand={allowExpand}
maxLength={maxLength}
disabled={disabled}
/>
}
/>
</StyledHeaderMetaInfo>
</StyledHeaderWrapper>
);
};

View File

@ -1,38 +0,0 @@
import type { VFC } from 'react';
import { Box } from '@mui/material';
import TrackChanges from '@mui/icons-material/TrackChanges';
interface IConstraintIconProps {
compact?: boolean;
disabled?: boolean;
}
/**
* @deprecated remove with `flagOverviewRedesign`
*/
export const ConstraintIcon: VFC<IConstraintIconProps> = ({
compact,
disabled,
}) => (
<Box
sx={(theme) => ({
backgroundColor: disabled
? theme.palette.neutral.border
: 'primary.light',
p: compact ? '1px' : '2px',
borderRadius: '50%',
width: compact ? '18px' : '24px',
height: compact ? '18px' : '24px',
marginRight: '13px',
})}
>
<TrackChanges
sx={(theme) => ({
fill: theme.palette.common.white,
display: 'block',
width: compact ? theme.spacing(2) : theme.spacing(2.5),
height: compact ? theme.spacing(2) : theme.spacing(2.5),
})}
/>
</Box>
);

View File

@ -1,5 +1,5 @@
import type React from 'react';
import { forwardRef, Fragment, useImperativeHandle } from 'react';
import { forwardRef, useImperativeHandle } from 'react';
import { styled } from '@mui/material';
import type { IConstraint } from 'interfaces/strategy';
import produce from 'immer';
@ -9,8 +9,6 @@ import {
constraintId,
createEmptyConstraint,
} from 'component/common/LegacyConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { NewConstraintAccordion } from 'component/common/NewConstraintAccordion/NewConstraintAccordion';
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
import { useUiFlag } from 'hooks/useUiFlag';
@ -87,7 +85,6 @@ export const NewConstraintAccordionList = forwardRef<
IConstraintList
>(({ constraints, setConstraints, state }, ref) => {
const { context } = useUnleashContext();
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const addEditStrategy = useUiFlag('addEditStrategy');
const onEdit =
@ -145,77 +142,43 @@ export const NewConstraintAccordionList = forwardRef<
return null;
}
if (flagOverviewRedesign) {
return (
<StyledContainer id={constraintAccordionListId}>
<ConstraintsList>
{constraints.map((constraint, index) =>
addEditStrategy ? (
state.get(constraint)?.editing ? (
<EditableConstraintWrapper
key={constraint[constraintId]}
constraint={constraint}
onCancel={onCancel?.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave!.bind(null, index)}
onAutoSave={onAutoSave?.(
constraint[constraintId],
)}
/>
) : (
<ConstraintAccordionView
key={constraint[constraintId]}
constraint={constraint}
/>
)
) : (
<NewConstraintAccordion
return (
<StyledContainer id={constraintAccordionListId}>
<ConstraintsList>
{constraints.map((constraint, index) =>
addEditStrategy ? (
state.get(constraint)?.editing ? (
<EditableConstraintWrapper
key={constraint[constraintId]}
constraint={constraint}
onEdit={onEdit?.bind(null, constraint)}
onCancel={onCancel.bind(null, index)}
onCancel={onCancel?.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave?.bind(null, index)}
onSave={onSave!.bind(null, index)}
onAutoSave={onAutoSave?.(
constraint[constraintId],
)}
editing={Boolean(
state.get(constraint)?.editing,
)}
compact
/>
),
)}
</ConstraintsList>
</StyledContainer>
);
}
return (
<StyledContainer id={constraintAccordionListId}>
{constraints.map((constraint, index) => {
const id = constraint[constraintId];
return (
<Fragment key={id}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
) : (
<ConstraintAccordionView
key={constraint[constraintId]}
constraint={constraint}
/>
)
) : (
<NewConstraintAccordion
key={constraint[constraintId]}
constraint={constraint}
onEdit={onEdit?.bind(null, constraint)}
onCancel={onCancel.bind(null, index)}
onDelete={onRemove?.bind(null, index)}
onSave={onSave?.bind(null, index)}
onAutoSave={onAutoSave?.(id)}
onAutoSave={onAutoSave?.(constraint[constraintId])}
editing={Boolean(state.get(constraint)?.editing)}
compact
/>
</Fragment>
);
})}
),
)}
</ConstraintsList>
</StyledContainer>
);
});

View File

@ -1,205 +0,0 @@
// deprecated; remove with the `flagOverviewRedesign` flag
import type React from 'react';
import type { DragEventHandler, FC, ReactNode } from 'react';
import DragIndicator from '@mui/icons-material/DragIndicator';
import { Box, IconButton, styled } from '@mui/material';
import type { IFeatureStrategy } from 'interfaces/strategy';
import {
formatStrategyName,
getFeatureStrategyIcon,
} from 'utils/strategyNames';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { PlaygroundStrategySchema } from 'openapi';
import { Badge } from '../Badge/Badge';
import { Link } from 'react-router-dom';
interface IStrategyItemContainerProps {
strategy: IFeatureStrategy | PlaygroundStrategySchema;
onDragStart?: DragEventHandler<HTMLButtonElement>;
onDragEnd?: DragEventHandler<HTMLButtonElement>;
actions?: ReactNode;
orderNumber?: number;
className?: string;
style?: React.CSSProperties;
description?: string;
children?: React.ReactNode;
}
const DragIcon = styled(IconButton)({
padding: 0,
cursor: 'inherit',
transition: 'color 0.2s ease-in-out',
});
const StyledIndexLabel = styled('div')(({ theme }) => ({
fontSize: theme.typography.fontSize,
color: theme.palette.text.secondary,
position: 'absolute',
display: 'none',
right: 'calc(100% + 6px)',
top: theme.spacing(2.5),
[theme.breakpoints.up('md')]: {
display: 'block',
},
}));
const StyledDescription = styled('div')(({ theme }) => ({
fontSize: theme.typography.fontSize,
fontWeight: 'normal',
color: theme.palette.text.secondary,
display: 'none',
top: theme.spacing(2.5),
[theme.breakpoints.up('md')]: {
display: 'block',
},
}));
const StyledCustomTitle = styled('div')(({ theme }) => ({
fontWeight: 'normal',
display: 'none',
[theme.breakpoints.up('md')]: {
display: 'block',
},
}));
const StyledHeaderContainer = styled('div')({
flexDirection: 'column',
justifyContent: 'center',
verticalAlign: 'middle',
});
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: disabled
? theme.palette.envAccordion.disabled
: theme.palette.background.paper,
}));
const StyledHeader = styled('div', {
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.text.secondary
: theme.palette.text.primary,
}),
);
export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
strategy,
onDragStart,
onDragEnd,
actions,
children,
orderNumber,
style = {},
description,
}) => {
const Icon = getFeatureStrategyIcon(strategy.name);
const StrategyHeaderLink: React.FC<{ children?: React.ReactNode }> =
'links' in strategy
? ({ children }) => <Link to={strategy.links.edit}>{children}</Link>
: ({ children }) => <> {children} </>;
return (
<Box sx={{ position: 'relative' }}>
<ConditionallyRender
condition={orderNumber !== undefined}
show={<StyledIndexLabel>{orderNumber}</StyledIndexLabel>}
/>
<StyledContainer
disabled={strategy?.disabled || false}
style={style}
>
<StyledHeader
draggable={Boolean(onDragStart)}
disabled={Boolean(strategy?.disabled)}
>
<ConditionallyRender
condition={Boolean(onDragStart)}
show={() => (
<DragIcon
draggable
disableRipple
size='small'
onDragStart={onDragStart}
onDragEnd={onDragEnd}
sx={{ cursor: 'move' }}
>
<DragIndicator
titleAccess='Drag to reorder'
cursor='grab'
sx={{ color: 'action.active' }}
/>
</DragIcon>
)}
/>
<Icon
sx={{
fill: (theme) => theme.palette.action.disabled,
}}
/>
<StyledHeaderContainer>
<StrategyHeaderLink>
<StringTruncator
maxWidth='400'
maxLength={15}
text={formatStrategyName(String(strategy.name))}
/>
<ConditionallyRender
condition={Boolean(strategy.title)}
show={
<StyledCustomTitle>
{formatStrategyName(
String(strategy.title),
)}
</StyledCustomTitle>
}
/>
</StrategyHeaderLink>
<ConditionallyRender
condition={Boolean(description)}
show={
<StyledDescription>
{description}
</StyledDescription>
}
/>
</StyledHeaderContainer>
<ConditionallyRender
condition={Boolean(strategy?.disabled)}
show={() => (
<>
<Badge color='disabled'>Disabled</Badge>
</>
)}
/>
<Box
sx={{
marginLeft: 'auto',
display: 'flex',
minHeight: (theme) => theme.spacing(6),
alignItems: 'center',
}}
>
{actions}
</Box>
</StyledHeader>
<Box sx={{ p: 2 }}>{children}</Box>
</StyledContainer>
</Box>
);
};

View File

@ -1,31 +1,8 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { StrategyItemContainer as LegacyStrategyItemContainer } from './LegacyStrategyItemContainer';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { StrategyItemContainer } from './StrategyItemContainer';
// todo: remove this test along with the flag flagOverviewRedesign
test('(deprecated) should render strategy name, custom title and description', async () => {
const strategy: IFeatureStrategy = {
id: 'irrelevant',
name: 'strategy name',
title: 'custom title',
constraints: [],
parameters: {},
};
render(
<LegacyStrategyItemContainer
strategy={strategy}
description={'description'}
/>,
);
expect(screen.getByText('strategy name')).toBeInTheDocument();
expect(screen.getByText('description')).toBeInTheDocument();
await screen.findByText('custom title'); // behind async flag
});
test('should render strategy name, custom title and description', async () => {
const strategy: IFeatureStrategy = {
id: 'irrelevant',

View File

@ -29,6 +29,9 @@ const StyledCenteredContent = styled(StyledContent)(({ theme }) => ({
padding: theme.spacing(0.75, 1.5),
}));
/**
* @deprecated remove with 'flagOverviewRedesign' flag. This pollutes a lot of places in the codebase 😞
*/
export const StrategySeparator = ({ text }: IStrategySeparatorProps) => {
const theme = useTheme();

View File

@ -1,106 +0,0 @@
import { type MouseEvent, useContext, useState, type VFC } from 'react';
import {
Button,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Tooltip,
} from '@mui/material';
import Lock from '@mui/icons-material/Lock';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import AccessContext from 'contexts/AccessContext';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface ICopyButtonProps {
environmentId: IFeatureEnvironment['name'];
environments: IFeatureEnvironment['name'][];
onClick: (environmentId: string) => void;
}
export const CopyButton: VFC<ICopyButtonProps> = ({
environmentId,
environments,
onClick,
}) => {
const projectId = useRequiredPathParam('projectId');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const { hasAccess } = useContext(AccessContext);
const enabled = environments.some((environment) =>
hasAccess(CREATE_FEATURE_STRATEGY, projectId, environment),
);
return (
<div>
<Tooltip title={enabled ? '' : '(Access denied)'}>
<div>
<Button
id={`copy-all-strategies-${environmentId}`}
aria-controls={open ? 'basic-menu' : undefined}
aria-haspopup='true'
aria-expanded={open ? 'true' : undefined}
onClick={(event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}}
disabled={!enabled}
variant='outlined'
>
Copy from another environment
</Button>
</div>
</Tooltip>
<Menu
id='basic-menu'
anchorEl={anchorEl}
open={open}
onClose={() => {
setAnchorEl(null);
}}
MenuListProps={{
'aria-labelledby': `copy-all-strategies-${environmentId}`,
}}
>
{environments.map((environment) => {
const access = hasAccess(
CREATE_FEATURE_STRATEGY,
projectId,
environment,
);
return (
<Tooltip
title={
access
? ''
: "You don't have access to add a strategy to this environment"
}
key={environment}
>
<div>
<MenuItem
onClick={() => onClick(environment)}
disabled={!access}
>
<ConditionallyRender
condition={!access}
show={
<ListItemIcon>
<Lock fontSize='small' />
</ListItemIcon>
}
/>
<ListItemText>
Copy from {environment}
</ListItemText>
</MenuItem>
</div>
</Tooltip>
);
})}
</Menu>
</div>
);
};

View File

@ -1,187 +0,0 @@
// deprecated; remove with the `flagOverviewRedesign` flag
import { Link } from 'react-router-dom';
import { Box, styled } from '@mui/material';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import useToast from 'hooks/useToast';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { CopyButton } from './CopyButton/CopyButton';
import { useChangeRequestAddStrategy } from 'hooks/useChangeRequestAddStrategy';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import { CopyStrategiesMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/CopyStrategiesMessage';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { FeatureStrategyMenuWrapper } from '../FeatureStrategyMenu/FeatureStrategyMenu';
interface IFeatureStrategyEmptyProps {
projectId: string;
featureId: string;
environmentId: string;
}
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
paddingTop: theme.spacing(2),
}));
const StyledTitle = styled('div')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
textAlign: 'center',
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
}));
const StyledDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
textAlign: 'center',
marginBottom: theme.spacing(3),
a: {
color: theme.palette.links,
},
}));
export const FeatureStrategyEmpty = ({
projectId,
featureId,
environmentId,
}: IFeatureStrategyEmptyProps) => {
const { addStrategyToFeature } = useFeatureStrategyApi();
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(projectId, featureId);
const { refetchFeature: refetchFeatureImmutable } = useFeatureImmutable(
projectId,
featureId,
);
const { feature } = useFeature(projectId, featureId);
const otherAvailableEnvironments = feature?.environments.filter(
(environment) =>
environment.name !== environmentId &&
environment.strategies &&
environment.strategies.length > 0,
);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const {
changeRequestDialogDetails,
onChangeRequestAddStrategies,
onChangeRequestAddStrategiesConfirm,
onChangeRequestAddStrategyClose,
} = useChangeRequestAddStrategy(projectId, featureId, 'addStrategy');
const onAfterAddStrategy = (multiple = false) => {
refetchFeature();
refetchFeatureImmutable();
setToastData({
text: multiple ? 'Strategies created' : 'Strategy created',
type: 'success',
});
};
const onCopyStrategies = async (fromEnvironmentName: string) => {
const strategies =
otherAvailableEnvironments?.find(
(environment) => environment.name === fromEnvironmentName,
)?.strategies || [];
if (isChangeRequestConfigured(environmentId)) {
await onChangeRequestAddStrategies(
environmentId,
strategies,
fromEnvironmentName,
);
return;
}
try {
await Promise.all(
strategies.map((strategy) => {
const { id, ...strategyCopy } = {
...strategy,
environment: environmentId,
};
return addStrategyToFeature(
projectId,
featureId,
environmentId,
strategyCopy,
);
}),
);
onAfterAddStrategy(true);
} catch (error) {
setToastApiError(formatUnknownError(error));
}
};
const canCopyFromOtherEnvironment =
otherAvailableEnvironments && otherAvailableEnvironments.length > 0;
return (
<>
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={onChangeRequestAddStrategyClose}
environment={changeRequestDialogDetails?.environment}
onConfirm={onChangeRequestAddStrategiesConfirm}
messageComponent={
<CopyStrategiesMessage
fromEnvironment={
changeRequestDialogDetails.fromEnvironment!
}
payload={changeRequestDialogDetails.strategies!}
/>
}
/>
<StyledContainer>
<StyledTitle>
You have not defined any strategies yet.
</StyledTitle>
<StyledDescription>
Strategies added in this environment will only be executed
if the SDK is using an{' '}
<Link to='/admin/api'>API key configured</Link> for this
environment.
</StyledDescription>
<Box
sx={{
w: '100%',
display: 'flex',
flexWrap: 'wrap',
gap: 2,
alignItems: 'center',
justifyContent: 'center',
}}
>
<FeatureStrategyMenuWrapper
label='Add your first strategy'
projectId={projectId}
featureId={featureId}
environmentId={environmentId}
matchWidth={canCopyFromOtherEnvironment}
/>
<ConditionallyRender
condition={canCopyFromOtherEnvironment}
show={
<CopyButton
environmentId={environmentId}
environments={otherAvailableEnvironments.map(
(environment) => environment.name,
)}
onClick={onCopyStrategies}
/>
}
/>
</Box>
</StyledContainer>
</>
);
};

View File

@ -13,7 +13,6 @@ import { useEffect, useState } from 'react';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
import { useUiFlag } from 'hooks/useUiFlag';
import { FeatureOverviewEnvironments } from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { default as LegacyFleatureOverview } from './LegacyFeatureOverview';
import { useEnvironmentVisibility } from './FeatureOverviewMetaData/EnvironmentVisibilityMenu/hooks/useEnvironmentVisibility';
import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi';
import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
@ -50,18 +49,12 @@ export const FeatureOverview = () => {
useEffect(() => {
setLastViewed({ featureId, projectId });
}, [featureId]);
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const { setSplashSeen } = useSplashApi();
const { splash } = useAuthSplash();
const [showTooltip, setShowTooltip] = useState(false);
const [hasClosedTooltip, setHasClosedTooltip] = useState(false);
const { feature, refetchFeature } = useFeature(projectId, featureId);
const cleanupReminderEnabled = useUiFlag('cleanupReminder');
if (!flagOverviewRedesign) {
return <LegacyFleatureOverview />;
}
const dragTooltipSplashId = 'strategy-drag-tooltip';
const shouldShowStrategyDragTooltip = !splash?.[dragTooltipSplashId];
const toggleShowTooltip = (envIsOpen: boolean) => {

View File

@ -1,354 +0,0 @@
// deprecated; remove with the `flagOverviewRedesign` flag
import {
type DragEventHandler,
type RefObject,
useEffect,
useState,
} from 'react';
import { Alert, Pagination, styled } 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/LegacyStrategyDraggableItem';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { FeatureStrategyEmpty } from 'component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import usePagination from 'hooks/usePagination';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { ReleasePlan } from '../../../ReleasePlan/LegacyReleasePlan';
import { Badge } from 'component/common/Badge/Badge';
import { SectionSeparator } from '../SectionSeparator/SectionSeparator';
interface IEnvironmentAccordionBodyProps {
isDisabled: boolean;
featureEnvironment?: IFeatureEnvironment;
otherEnvironments?: IFeatureEnvironment['name'][];
}
const StyledAccordionBody = styled('div')(({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}));
const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1),
},
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
backgroundColor: theme.palette.primary.light,
border: 'none',
padding: theme.spacing(0.75, 1.5),
borderRadius: theme.shape.borderRadiusLarge,
color: theme.palette.common.white,
}));
const AdditionalStrategiesDiv = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: theme.spacing(2),
}));
const EnvironmentAccordionBody = ({
featureEnvironment,
isDisabled,
otherEnvironments,
}: IEnvironmentAccordionBodyProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { setStrategiesSortOrder } = useFeatureStrategyApi();
const { addChange } = useChangeRequestApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(projectId, featureId);
const manyStrategiesPagination = useUiFlag('manyStrategiesPagination');
const [strategies, setStrategies] = useState(
featureEnvironment?.strategies || [],
);
const { releasePlans } = useReleasePlans(
projectId,
featureId,
featureEnvironment?.name,
);
const { trackEvent } = usePlausibleTracker();
const [dragItem, setDragItem] = useState<{
id: string;
index: number;
height: number;
} | null>(null);
useEffect(() => {
// Use state to enable drag and drop, but switch to API output when it arrives
setStrategies(featureEnvironment?.strategies || []);
}, [featureEnvironment?.strategies]);
useEffect(() => {
if (strategies.length > 50) {
trackEvent('many-strategies');
}
}, []);
const pageSize = 20;
const { page, pages, setPageIndex, pageIndex } =
usePagination<IFeatureStrategy>(strategies, pageSize);
if (!featureEnvironment) {
return null;
}
const onReorder = async (payload: { id: string; sortOrder: number }[]) => {
try {
await setStrategiesSortOrder(
projectId,
featureId,
featureEnvironment.name,
payload,
);
refetchFeature();
setToastData({
text: 'Order of strategies updated',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onChangeRequestReorder = async (
payload: { id: string; sortOrder: number }[],
) => {
await addChange(projectId, featureEnvironment.name, {
action: 'reorderStrategy',
feature: featureId,
payload,
});
setToastData({
text: 'Strategy execution order added to draft',
type: 'success',
});
refetchChangeRequests();
};
const onStrategyReorder = async (
payload: { id: string; sortOrder: number }[],
) => {
try {
if (isChangeRequestConfigured(featureEnvironment.name)) {
await onChangeRequestReorder(payload);
} else {
await onReorder(payload);
}
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onDragStartRef =
(
ref: RefObject<HTMLDivElement>,
index: number,
): DragEventHandler<HTMLButtonElement> =>
(event) => {
setDragItem({
id: strategies[index].id,
index,
height: ref.current?.offsetHeight || 0,
});
if (ref?.current) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', ref.current.outerHTML);
event.dataTransfer.setDragImage(ref.current, 20, 20);
}
};
const onDragOver =
(targetId: string) =>
(
ref: RefObject<HTMLDivElement>,
targetIndex: number,
): DragEventHandler<HTMLDivElement> =>
(event) => {
if (dragItem === null || ref.current === null) return;
if (dragItem.index === targetIndex || targetId === dragItem.id)
return;
const { top, bottom } = ref.current.getBoundingClientRect();
const overTargetTop = event.clientY - top < dragItem.height;
const overTargetBottom = bottom - event.clientY < dragItem.height;
const draggingUp = dragItem.index > targetIndex;
// prevent oscillating by only reordering if there is sufficient space
if (
(overTargetTop && draggingUp) ||
(overTargetBottom && !draggingUp)
) {
const newStrategies = [...strategies];
const movedStrategy = newStrategies.splice(
dragItem.index,
1,
)[0];
newStrategies.splice(targetIndex, 0, movedStrategy);
setStrategies(newStrategies);
setDragItem({
...dragItem,
index: targetIndex,
});
}
};
const onDragEnd = () => {
setDragItem(null);
onStrategyReorder(
strategies.map((strategy, sortOrder) => ({
id: strategy.id,
sortOrder,
})),
);
};
return (
<StyledAccordionBody>
<StyledAccordionBodyInnerContainer>
<ConditionallyRender
condition={
(releasePlans.length > 0 || 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={releasePlans.length > 0 || strategies.length > 0}
show={
<>
{releasePlans.map((plan) => (
<ReleasePlan
key={plan.id}
plan={plan}
environmentIsDisabled={isDisabled}
/>
))}
<ConditionallyRender
condition={
releasePlans.length > 0 &&
strategies.length > 0
}
show={
<>
<SectionSeparator>
<StyledBadge>OR</StyledBadge>
</SectionSeparator>
<AdditionalStrategiesDiv>
Additional strategies
</AdditionalStrategiesDiv>
</>
}
/>
<ConditionallyRender
condition={
strategies.length < 50 ||
!manyStrategiesPagination
}
show={
<>
{strategies.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={
dragItem?.id === strategy.id
}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(
strategy.id,
)}
onDragEnd={onDragEnd}
/>
))}
</>
}
elseShow={
<>
<Alert severity='error'>
We noticed you're using a high
number of activation strategies. To
ensure a more targeted approach,
consider leveraging constraints or
segments.
</Alert>
<br />
{page.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={
index + pageIndex * pageSize
}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={false}
onDragStartRef={
(() => {}) as any
}
onDragOver={(() => {}) as any}
onDragEnd={(() => {}) as any}
/>
))}
<br />
<Pagination
count={pages.length}
shape='rounded'
page={pageIndex + 1}
onChange={(_, page) =>
setPageIndex(page - 1)
}
/>
</>
}
/>
</>
}
elseShow={
<FeatureStrategyEmpty
projectId={projectId}
featureId={featureId}
environmentId={featureEnvironment.name}
/>
}
/>
</StyledAccordionBodyInnerContainer>
</StyledAccordionBody>
);
};
export default EnvironmentAccordionBody;

View File

@ -1,150 +0,0 @@
// deprecated; remove with the `flagOverviewRedesign` flag
import { type DragEventHandler, type RefObject, useRef } from 'react';
import { Box, useMediaQuery, useTheme } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { StrategyItem } from './StrategyItem/LegacyStrategyItem';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import {
useStrategyChangesFromRequest,
type UseStrategyChangeFromRequestResult,
} from './StrategyItem/useStrategyChangesFromRequest';
import { ChangesScheduledBadge } from 'component/changeRequest/ModifiedInChangeRequestStatusBadge/ChangesScheduledBadge';
import type { IFeatureChange } from 'component/changeRequest/changeRequest.types';
import { Badge } from 'component/common/Badge/Badge';
import {
type ScheduledChangeRequestViewModel,
useScheduledChangeRequestsWithStrategy,
} from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
interface IStrategyDraggableItemProps {
strategy: IFeatureStrategy;
environmentName: string;
index: number;
otherEnvironments?: IFeatureEnvironment['name'][];
isDragging?: boolean;
onDragStartRef: (
ref: RefObject<HTMLDivElement>,
index: number,
) => DragEventHandler<HTMLButtonElement>;
onDragOver: (
ref: RefObject<HTMLDivElement>,
index: number,
) => DragEventHandler<HTMLDivElement>;
onDragEnd: () => void;
}
export const StrategyDraggableItem = ({
strategy,
index,
environmentName,
otherEnvironments,
isDragging,
onDragStartRef,
onDragOver,
onDragEnd,
}: IStrategyDraggableItemProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const ref = useRef<HTMLDivElement>(null);
const strategyChangesFromRequest = useStrategyChangesFromRequest(
projectId,
featureId,
environmentName,
strategy.id,
);
const { changeRequests: scheduledChangesUsingStrategy } =
useScheduledChangeRequestsWithStrategy(projectId, strategy.id);
return (
<Box
key={strategy.id}
ref={ref}
onDragOver={onDragOver(ref, index)}
sx={{ opacity: isDragging ? '0.5' : '1' }}
>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='OR' />}
/>
<StrategyItem
strategy={strategy}
environmentId={environmentName}
otherEnvironments={otherEnvironments}
onDragStart={onDragStartRef(ref, index)}
onDragEnd={onDragEnd}
orderNumber={index + 1}
headerChildren={renderHeaderChildren(
strategyChangesFromRequest,
scheduledChangesUsingStrategy,
)}
/>
</Box>
);
};
const ChangeRequestStatusBadge = ({
change,
}: {
change: IFeatureChange | undefined;
}) => {
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
if (isSmallScreen) {
return null;
}
return (
<Box sx={{ mr: 1.5 }}>
<ConditionallyRender
condition={change?.action === 'updateStrategy'}
show={<Badge color='warning'>Modified in draft</Badge>}
/>
<ConditionallyRender
condition={change?.action === 'deleteStrategy'}
show={<Badge color='error'>Deleted in draft</Badge>}
/>
</Box>
);
};
const renderHeaderChildren = (
changes?: UseStrategyChangeFromRequestResult,
scheduledChanges?: ScheduledChangeRequestViewModel[],
): JSX.Element[] => {
const badges: JSX.Element[] = [];
if (changes?.length === 0 && scheduledChanges?.length === 0) {
return [];
}
const draftChange = changes?.find(
({ isScheduledChange }) => !isScheduledChange,
);
if (draftChange) {
badges.push(
<ChangeRequestStatusBadge
key={`draft-change#${draftChange.change.id}`}
change={draftChange.change}
/>,
);
}
if (scheduledChanges && scheduledChanges.length > 0) {
badges.push(
<ChangesScheduledBadge
key='scheduled-changes'
scheduledChangeRequestIds={scheduledChanges.map(
(scheduledChange) => scheduledChange.id,
)}
/>,
);
}
return badges;
};

View File

@ -1,103 +0,0 @@
// deprecated; remove with the `flagOverviewRedesign` flag
import type { DragEventHandler, FC } from 'react';
import Edit from '@mui/icons-material/Edit';
import { Link } from 'react-router-dom';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import type { IFeatureStrategy } from 'interfaces/strategy';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { CopyStrategyIconMenu } from './CopyStrategyIconMenu/CopyStrategyIconMenu';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/LegacyStrategyItemContainer';
import MenuStrategyRemove from './MenuStrategyRemove/MenuStrategyRemove';
import { VariantsSplitPreview } from 'component/common/VariantsSplitPreview/VariantsSplitPreview';
import { Box } from '@mui/material';
interface IStrategyItemProps {
environmentId: string;
strategy: IFeatureStrategy;
onDragStart?: DragEventHandler<HTMLButtonElement>;
onDragEnd?: DragEventHandler<HTMLButtonElement>;
otherEnvironments?: IFeatureEnvironment['name'][];
orderNumber?: number;
headerChildren?: JSX.Element[] | JSX.Element;
}
export const StrategyItem: FC<IStrategyItemProps> = ({
environmentId,
strategy,
onDragStart,
onDragEnd,
otherEnvironments,
orderNumber,
headerChildren,
}) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const editStrategyPath = formatEditStrategyPath(
projectId,
featureId,
environmentId,
strategy.id,
);
return (
<StrategyItemContainer
strategy={strategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
orderNumber={orderNumber}
actions={
<>
{headerChildren}
<ConditionallyRender
condition={Boolean(
otherEnvironments && otherEnvironments?.length > 0,
)}
show={() => (
<CopyStrategyIconMenu
environmentId={environmentId}
environments={otherEnvironments as string[]}
strategy={strategy}
/>
)}
/>
<PermissionIconButton
permission={UPDATE_FEATURE_STRATEGY}
environmentId={environmentId}
projectId={projectId}
component={Link}
to={editStrategyPath}
tooltipProps={{
title: 'Edit strategy',
}}
data-testid={`STRATEGY_EDIT-${strategy.name}`}
>
<Edit />
</PermissionIconButton>
<MenuStrategyRemove
projectId={projectId}
featureId={featureId}
environmentId={environmentId}
strategy={strategy}
/>
</>
}
>
<StrategyExecution strategy={strategy} />
{strategy.variants &&
strategy.variants.length > 0 &&
(strategy.disabled ? (
<Box sx={{ opacity: '0.5' }}>
<VariantsSplitPreview variants={strategy.variants} />
</Box>
) : (
<VariantsSplitPreview variants={strategy.variants} />
))}
</StrategyItemContainer>
);
};

View File

@ -1,372 +0,0 @@
import { type FC, Fragment, useMemo } from 'react';
import { Alert, Box, Chip, Link, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { ConstraintItem } from './ConstraintItem/LegacyConstraintItem';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { FeatureOverviewSegment } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSegment/FeatureOverviewSegment';
import { ConstraintAccordionList } from 'component/common/LegacyConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
import {
parseParameterNumber,
parseParameterString,
parseParameterStrings,
} from 'utils/parseParameter';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Badge } from 'component/common/Badge/Badge';
import type { CreateFeatureStrategySchema } from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import { BuiltInStrategies } from 'utils/strategyNames';
interface IStrategyExecutionProps {
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
displayGroupId?: boolean;
}
const StyledContainer = styled(Box, {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean | null }>(({ theme, disabled }) => ({
'& p, & span, & h1, & h2, & h3, & h4, & h5, & h6': {
color: disabled ? theme.palette.neutral.main : 'inherit',
},
'.constraint-icon-container': {
backgroundColor: disabled
? theme.palette.neutral.border
: theme.palette.primary.light,
borderRadius: '50%',
},
'.constraint-icon': {
fill: disabled
? theme.palette.neutral.light
: theme.palette.common.white,
},
}));
const CustomStrategyDeprecationWarning = () => (
<Alert severity='warning' sx={{ mb: 2 }}>
Custom strategies are deprecated and may be removed in a future major
version. Consider rewriting this strategy as a predefined strategy with{' '}
<Link
href={
'https://docs.getunleash.io/reference/activation-strategies#constraints'
}
target='_blank'
variant='body2'
>
constraints.
</Link>
</Alert>
);
const NoItems: FC = () => (
<Box sx={{ px: 3, color: 'text.disabled' }}>
This strategy does not have constraints or parameters.
</Box>
);
const StyledValueContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(2, 3),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
background: theme.palette.background.default,
}));
const StyledValueSeparator = styled('span')(({ theme }) => ({
color: theme.palette.neutral.main,
}));
export const StrategyExecution: FC<IStrategyExecutionProps> = ({
strategy,
displayGroupId = false,
}) => {
const { parameters, constraints = [] } = strategy;
const stickiness = parameters?.stickiness;
const explainStickiness =
typeof stickiness === 'string' && stickiness !== 'default';
const { strategies } = useStrategies();
const { segments } = useSegments();
const strategySegments = segments?.filter((segment) => {
return strategy.segments?.includes(segment.id);
});
const definition = strategies.find((strategyDefinition) => {
return strategyDefinition.name === strategy.name;
});
const parametersList = useMemo(() => {
if (!parameters || definition?.editable) return null;
return Object.keys(parameters).map((key) => {
switch (key) {
case 'rollout':
case 'Rollout': {
const percentage = parseParameterNumber(parameters[key]);
const badgeType = strategy.disabled ? 'neutral' : 'success';
return (
<StyledValueContainer
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: 2 }}>
<PercentageCircle
percentage={percentage}
size='2rem'
disabled={strategy.disabled}
/>
</Box>
<div>
<Badge color={badgeType}>{percentage}%</Badge>{' '}
<span>of your base</span>{' '}
<span>
{explainStickiness ? (
<>
with <strong>{stickiness}</strong>
</>
) : (
''
)}{' '}
</span>
<span>
{constraints.length > 0
? 'who match constraints'
: ''}{' '}
is included.
</span>
</div>
{displayGroupId && parameters.groupId && (
<Box
sx={(theme) => ({
ml: 1,
color: theme.palette.info.contrastText,
})}
>
<Badge color='info'>
GroupId: {parameters.groupId}
</Badge>
</Box>
)}
</StyledValueContainer>
);
}
case 'userIds':
case 'UserIds': {
const users = parseParameterStrings(parameters[key]);
return (
<ConstraintItem key={key} value={users} text='user' />
);
}
case 'hostNames':
case 'HostNames': {
const hosts = parseParameterStrings(parameters[key]);
return (
<ConstraintItem key={key} value={hosts} text={'host'} />
);
}
case 'IPs': {
const IPs = parseParameterStrings(parameters[key]);
return <ConstraintItem key={key} value={IPs} text={'IP'} />;
}
case 'stickiness':
case 'groupId':
return null;
default:
return null;
}
});
}, [parameters, definition, constraints, strategy.disabled]);
const customStrategyList = useMemo(() => {
if (!parameters || !definition?.editable) return null;
const isSetTo = (
<StyledValueSeparator>{' is set to '}</StyledValueSeparator>
);
return definition?.parameters.map((param) => {
const { type, name } = { ...param };
if (!type || !name || parameters[name] === undefined) {
return null;
}
const nameItem = (
<StringTruncator maxLength={15} maxWidth='150' text={name} />
);
switch (param?.type) {
case 'list': {
const values = parseParameterStrings(parameters[name]);
return values.length > 0 ? (
<StyledValueContainer>
{nameItem}{' '}
<StyledValueSeparator>
has {values.length}{' '}
{values.length > 1 ? `items` : 'item'}:{' '}
{values.map((item: string) => (
<Chip
key={item}
label={
<StringTruncator
maxWidth='300'
text={item}
maxLength={50}
/>
}
sx={{ mr: 0.5 }}
/>
))}
</StyledValueSeparator>
</StyledValueContainer>
) : null;
}
case 'percentage': {
const percentage = parseParameterNumber(parameters[name]);
return parameters[name] !== '' ? (
<StyledValueContainer
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: 2 }}>
<PercentageCircle
percentage={percentage}
size='2rem'
/>
</Box>
<div>
{nameItem}
{isSetTo}
<Badge color='success'>{percentage}%</Badge>
</div>
</StyledValueContainer>
) : null;
}
case 'boolean':
return parameters[name] === 'true' ||
parameters[name] === 'false' ? (
<StyledValueContainer>
<StringTruncator
maxLength={15}
maxWidth='150'
text={name}
/>
{isSetTo}
<Badge
color={
parameters[name] === 'true'
? 'success'
: 'error'
}
>
{parameters[name]}
</Badge>
</StyledValueContainer>
) : null;
case 'string': {
const value = parseParameterString(parameters[name]);
return typeof parameters[name] !== 'undefined' ? (
<StyledValueContainer>
{nameItem}
<ConditionallyRender
condition={value === ''}
show={
<StyledValueSeparator>
{' is an empty string'}
</StyledValueSeparator>
}
elseShow={
<>
{isSetTo}
<StringTruncator
maxWidth='300'
text={value}
maxLength={50}
/>
</>
}
/>
</StyledValueContainer>
) : null;
}
case 'number': {
const number = parseParameterNumber(parameters[name]);
return parameters[name] !== '' && number !== undefined ? (
<StyledValueContainer>
{nameItem}
{isSetTo}
<StringTruncator
maxWidth='300'
text={String(number)}
maxLength={50}
/>
</StyledValueContainer>
) : null;
}
case 'default':
return null;
}
return null;
});
}, [parameters, definition]);
if (!parameters) {
return <NoItems />;
}
const listItems = [
strategySegments && strategySegments.length > 0 && (
<FeatureOverviewSegment
segments={strategySegments}
disabled={strategy.disabled}
/>
),
constraints.length > 0 && (
<ConstraintAccordionList
constraints={constraints}
showLabel={false}
/>
),
strategy.name === 'default' && (
<>
<StyledValueContainer sx={{ width: '100%' }}>
The standard strategy is <Badge color='success'>ON</Badge>{' '}
for all users.
</StyledValueContainer>
</>
),
...(parametersList ?? []),
...(customStrategyList ?? []),
].filter(Boolean);
return (
<>
<ConditionallyRender
condition={
!BuiltInStrategies.includes(strategy.name || 'default')
}
show={<CustomStrategyDeprecationWarning />}
/>
<ConditionallyRender
condition={listItems.length > 0}
show={
<StyledContainer disabled={Boolean(strategy.disabled)}>
{listItems.map((item, index) => (
<Fragment key={index}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
{item}
</Fragment>
))}
</StyledContainer>
}
elseShow={<NoItems />}
/>
</>
);
};

View File

@ -1,8 +1,6 @@
import type { FC } from 'react';
import type { FeatureStrategySchema } from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import { useUiFlag } from 'hooks/useUiFlag';
import { StrategyExecution as LegacyStrategyExecution } from './LegacyStrategyExecution';
import { ConstraintAccordionView } from 'component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { objectId } from 'utils/objectId';
@ -37,16 +35,6 @@ export const StrategyExecution: FC<StrategyExecutionProps> = ({
const strategySegments = segments?.filter((segment) =>
strategy.segments?.includes(segment.id),
);
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
if (!flagOverviewRedesign) {
return (
<LegacyStrategyExecution
strategy={strategy}
displayGroupId={displayGroupId}
/>
);
}
return (
<>

View File

@ -1,131 +0,0 @@
import FiberManualRecord from '@mui/icons-material/FiberManualRecord';
import { useTheme } from '@mui/system';
import type { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle';
import { calculatePercentage } from 'utils/calculatePercentage';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { PrettifyLargeNumber } from 'component/common/PrettifyLargeNumber/PrettifyLargeNumber';
import { styled } from '@mui/material';
interface IFeatureOverviewEnvironmentMetrics {
environmentMetric?: IFeatureEnvironmentMetrics;
disabled?: boolean;
}
const StyledContainer = styled('div')({
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
});
const StyledInfo = styled('div')(({ theme }) => ({
marginRight: theme.spacing(1),
display: 'flex',
flexDirection: 'column',
}));
const StyledPercentage = styled('p')(({ theme }) => ({
color: theme.palette.primary.main,
textAlign: 'right',
fontSize: theme.fontSizes.bodySize,
}));
const StyledInfoParagraph = styled('p')(({ theme }) => ({
maxWidth: '270px',
marginTop: theme.spacing(0.5),
fontSize: theme.fontSizes.smallBody,
textAlign: 'right',
[theme.breakpoints.down(700)]: {
display: 'none',
},
}));
const StyledIcon = styled(FiberManualRecord)(({ theme }) => ({
fill: theme.palette.background.elevation2,
height: '75px',
width: '75px',
[theme.breakpoints.down(500)]: {
display: 'none',
},
}));
const StyledPercentageCircle = styled('div')(({ theme }) => ({
margin: theme.spacing(0, 2),
[theme.breakpoints.down(500)]: {
display: 'none',
},
}));
/**
* @deprecated remove with `flagOverviewRedesign` flag
*/
const FeatureOverviewEnvironmentMetrics = ({
environmentMetric,
disabled = false,
}: IFeatureOverviewEnvironmentMetrics) => {
const theme = useTheme();
if (!environmentMetric) return null;
const total = environmentMetric.yes + environmentMetric.no;
const percentage = calculatePercentage(total, environmentMetric?.yes);
if (
!environmentMetric ||
(environmentMetric.yes === 0 && environmentMetric.no === 0)
) {
return (
<StyledContainer>
<StyledInfo>
<StyledPercentage
style={{
color: disabled
? theme.palette.text.secondary
: undefined,
}}
data-loading
>
{percentage}%
</StyledPercentage>
<StyledInfoParagraph
style={{
color: disabled
? theme.palette.text.secondary
: theme.palette.text.primary,
}}
data-loading
>
The feature has been requested <b>0 times</b> and
exposed
<b> 0 times</b> in the last hour
</StyledInfoParagraph>
</StyledInfo>
<StyledIcon style={{ transform: 'scale(1.1)' }} data-loading />
</StyledContainer>
);
}
return (
<StyledContainer>
<StyledInfo>
<StyledPercentage>{percentage}%</StyledPercentage>
<StyledInfoParagraph>
The feature has been requested{' '}
<b>
<PrettifyLargeNumber value={total} /> times
</b>{' '}
and exposed{' '}
<b>
<PrettifyLargeNumber value={environmentMetric.yes} />{' '}
times
</b>{' '}
in the last hour
</StyledInfoParagraph>
</StyledInfo>
<StyledPercentageCircle data-loading>
<PercentageCircle percentage={percentage} size='3rem' />
</StyledPercentageCircle>
</StyledContainer>
);
};
export default FeatureOverviewEnvironmentMetrics;

View File

@ -1,252 +0,0 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
styled,
} from '@mui/material';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import EnvironmentAccordionBody from './EnvironmentAccordionBody/LegacyEnvironmentAccordionBody';
import { EnvironmentFooter } from './EnvironmentFooter/EnvironmentFooter';
import FeatureOverviewEnvironmentMetrics from './EnvironmentHeader/FeatureOverviewEnvironmentMetrics/LegacyFeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenuWrapper } 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 { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { Badge } from 'component/common/Badge/Badge';
import { UpgradeChangeRequests } from './UpgradeChangeRequests/UpgradeChangeRequests';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
}
const StyledFeatureOverviewEnvironment = styled('div', {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
borderRadius: theme.shape.borderRadiusLarge,
marginBottom: theme.spacing(2),
backgroundColor: enabled
? theme.palette.background.paper
: theme.palette.envAccordion.disabled,
}));
const StyledAccordion = styled(Accordion)({
boxShadow: 'none',
background: 'none',
});
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
boxShadow: 'none',
padding: theme.spacing(2, 4),
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1, 2),
},
}));
const StyledAccordionDetails = styled(AccordionDetails, {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme }) => ({
padding: theme.spacing(3),
background: theme.palette.envAccordion.expanded,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.accordionFooter,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 1),
},
}));
const StyledEnvironmentAccordionBody = styled(EnvironmentAccordionBody)(
({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}),
);
const StyledHeader = styled('div', {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
color: enabled ? theme.palette.text.primary : theme.palette.text.secondary,
}));
const StyledHeaderTitle = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
textAlign: 'center',
},
}));
const StyledEnvironmentIcon = styled(EnvironmentIcon)(({ theme }) => ({
[theme.breakpoints.down(560)]: {
marginBottom: '0.5rem',
},
}));
const StyledStringTruncator = styled(StringTruncator)(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightMedium,
[theme.breakpoints.down(560)]: {
textAlign: 'center',
},
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginTop: theme.spacing(2),
gap: theme.spacing(2),
flexWrap: 'wrap',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
},
}));
/**
* @deprecated remove this file with `flagOverviewRedesign`
*/
const FeatureOverviewEnvironment = ({
env,
}: IFeatureOverviewEnvironmentProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { metrics } = useFeatureMetrics(projectId, featureId);
const { feature } = useFeature(projectId, featureId);
const { value: globalStore } = useGlobalLocalStorage();
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const environmentMetric = featureMetrics.find(
(featureMetric) => featureMetric.environment === env.name,
);
const featureEnvironment = feature?.environments.find(
(featureEnvironment) => featureEnvironment.name === env.name,
);
const { isOss } = useUiConfig();
const showChangeRequestUpgrade = env.type === 'production' && isOss();
return (
<ConditionallyRender
condition={!new Set(globalStore.hiddenEnvironments).has(env.name)}
show={
<StyledFeatureOverviewEnvironment enabled={env.enabled}>
<StyledAccordion
TransitionProps={{ mountOnEnter: true }}
data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${env.name}`}
className={`environment-accordion ${
env.enabled ? '' : 'accordion-disabled'
}`}
>
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
>
<StyledHeader data-loading enabled={env.enabled}>
<StyledHeaderTitle>
<StyledEnvironmentIcon
enabled={env.enabled}
/>
<div>
<StyledStringTruncator
text={env.name}
maxWidth='100'
maxLength={15}
/>
</div>
<ConditionallyRender
condition={!env.enabled}
show={
<Badge
color='neutral'
sx={{ ml: 1 }}
>
Disabled
</Badge>
}
/>
</StyledHeaderTitle>
<StyledButtonContainer>
<FeatureStrategyMenuWrapper
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={env.name}
variant='outlined'
size='small'
/>
<FeatureStrategyIcons
strategies={
featureEnvironment?.strategies
}
/>
</StyledButtonContainer>
</StyledHeader>
<FeatureOverviewEnvironmentMetrics
environmentMetric={environmentMetric}
disabled={!env.enabled}
/>
</StyledAccordionSummary>
<StyledAccordionDetails enabled={env.enabled}>
<StyledEnvironmentAccordionBody
featureEnvironment={featureEnvironment}
isDisabled={!env.enabled}
otherEnvironments={feature?.environments
.map(({ name }) => name)
.filter((name) => name !== env.name)}
/>
<ConditionallyRender
condition={
(featureEnvironment?.strategies?.length ||
0) > 0
}
show={
<>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 1,
}}
>
<FeatureStrategyMenuWrapper
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={env.name}
/>
</Box>
<EnvironmentFooter
environmentMetric={
environmentMetric
}
/>
{showChangeRequestUpgrade ? (
<UpgradeChangeRequests />
) : null}
</>
}
/>
</StyledAccordionDetails>
</StyledAccordion>
</StyledFeatureOverviewEnvironment>
}
/>
);
};
export default FeatureOverviewEnvironment;

View File

@ -1,8 +1,6 @@
import type { ComponentProps, FC } from 'react';
import { useUiFlag } from 'hooks/useUiFlag';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { FeatureOverviewEnvironment } from './FeatureOverviewEnvironment/FeatureOverviewEnvironment';
import LegacyFeatureOverviewEnvironment from './FeatureOverviewEnvironment/LegacyFeatureOverviewEnvironment';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
@ -40,23 +38,9 @@ export const FeatureOverviewEnvironments: FC<
const { feature } = useFeature(projectId, featureId);
const { metrics } = useFeatureMetrics(projectId, featureId);
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
if (!feature) return null;
if (!flagOverviewRedesign) {
return (
<>
{feature.environments?.map((env) => (
<LegacyFeatureOverviewEnvironment
env={env}
key={env.name}
/>
))}
</>
);
}
return feature.environments
?.filter((env) => !hiddenEnvironments.includes(env.name))
.map((env) => (

View File

@ -1,104 +0,0 @@
import type React from 'react';
import { type FC, useState } from 'react';
import {
IconButton,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
Popover,
styled,
Tooltip,
Typography,
Box,
} from '@mui/material';
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import MoreVert from '@mui/icons-material/MoreVert';
const StyledPopover = styled(Popover)(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
padding: theme.spacing(1, 1.5),
}));
export const OldDependencyActions: FC<{
feature: string;
onEdit: () => void;
onDelete: () => void;
}> = ({ feature, onEdit, onDelete }) => {
const id = `dependency-${feature}-actions`;
const menuId = `${id}-menu`;
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const openActions = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const closeActions = () => {
setAnchorEl(null);
};
return (
<Box>
<Tooltip title='Dependency actions' arrow describeChild>
<IconButton
sx={{ mr: 0.25 }}
id={id}
aria-controls={open ? menuId : undefined}
aria-haspopup='true'
aria-expanded={open ? 'true' : undefined}
onClick={openActions}
type='button'
>
<MoreVert />
</IconButton>
</Tooltip>
<StyledPopover
id={menuId}
anchorEl={anchorEl}
open={open}
onClose={closeActions}
transformOrigin={{
horizontal: 'right',
vertical: 'top',
}}
anchorOrigin={{
horizontal: 'right',
vertical: 'bottom',
}}
disableScrollLock={true}
>
<MenuList aria-labelledby={id}>
<MenuItem
onClick={() => {
onEdit();
closeActions();
}}
>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>Edit</Typography>
</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
onDelete();
closeActions();
}}
>
<ListItemIcon>
<Delete />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>Delete</Typography>
</ListItemText>
</MenuItem>
</MenuList>
</StyledPopover>
</Box>
);
};

View File

@ -1,218 +0,0 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { AddDependencyDialogue } from 'component/feature/Dependencies/AddDependencyDialogue';
import type { IFeatureToggle } from 'interfaces/featureToggle';
import { type FC, useState } from 'react';
import {
FlexRow,
StyledDetail,
StyledLabel,
StyledLink,
} from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow';
import { OldDependencyActions } from './OldDependencyActions';
import { useDependentFeaturesApi } from 'hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { ChildrenTooltip } from './ChildrenTooltip';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { UPDATE_FEATURE_DEPENDENCY } from 'component/providers/AccessProvider/permissions';
import { useCheckProjectAccess } from 'hooks/useHasAccess';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import useToast from 'hooks/useToast';
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { formatUnknownError } from 'utils/formatUnknownError';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { VariantsTooltip } from './VariantsTooltip';
const useDeleteDependency = (project: string, featureId: string) => {
const { trackEvent } = usePlausibleTracker();
const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(project);
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(project, featureId);
const environment = useHighestPermissionChangeRequestEnvironment(project)();
const { isChangeRequestConfiguredInAnyEnv } =
useChangeRequestsEnabled(project);
const { removeDependencies } = useDependentFeaturesApi(project);
const handleAddChange = async () => {
if (!environment) {
console.error('No change request environment');
return;
}
await addChange(project, environment, [
{
action: 'deleteDependency',
feature: featureId,
payload: undefined,
},
]);
};
const deleteDependency = async () => {
try {
if (isChangeRequestConfiguredInAnyEnv()) {
await handleAddChange();
trackEvent('dependent_features', {
props: {
eventType: 'delete dependency added to change request',
},
});
setToastData({
type: 'success',
text: 'Change added to draft',
});
await refetchChangeRequests();
} else {
await removeDependencies(featureId);
trackEvent('dependent_features', {
props: {
eventType: 'dependency removed',
},
});
setToastData({ text: 'Dependency removed', type: 'success' });
await refetchFeature();
}
} catch (error) {
setToastApiError(formatUnknownError(error));
}
};
return deleteDependency;
};
export const OldDependencyRow: FC<{ feature: IFeatureToggle }> = ({
feature,
}) => {
const [showDependencyDialogue, setShowDependencyDialogue] = useState(false);
const canAddParentDependency =
Boolean(feature.project) &&
feature.dependencies.length === 0 &&
feature.children.length === 0;
const hasParentDependency =
Boolean(feature.project) && Boolean(feature.dependencies.length > 0);
const hasChildren = Boolean(feature.project) && feature.children.length > 0;
const environment = useHighestPermissionChangeRequestEnvironment(
feature.project,
)();
const checkAccess = useCheckProjectAccess(feature.project);
const deleteDependency = useDeleteDependency(feature.project, feature.name);
return (
<>
<ConditionallyRender
condition={canAddParentDependency}
show={
<FlexRow>
<StyledDetail>
<StyledLabel>Dependency:</StyledLabel>
<PermissionButton
size='small'
permission={UPDATE_FEATURE_DEPENDENCY}
projectId={feature.project}
variant='text'
onClick={() => {
setShowDependencyDialogue(true);
}}
sx={(theme) => ({
marginBottom: theme.spacing(0.4),
})}
>
Add parent flag
</PermissionButton>
</StyledDetail>
</FlexRow>
}
/>
<ConditionallyRender
condition={hasParentDependency}
show={
<FlexRow>
<StyledDetail>
<StyledLabel>Dependency:</StyledLabel>
<StyledLink
to={`/projects/${feature.project}/features/${feature.dependencies[0]?.feature}`}
>
{feature.dependencies[0]?.feature}
</StyledLink>
</StyledDetail>
<ConditionallyRender
condition={checkAccess(
UPDATE_FEATURE_DEPENDENCY,
environment,
)}
show={
<OldDependencyActions
feature={feature.name}
onEdit={() =>
setShowDependencyDialogue(true)
}
onDelete={deleteDependency}
/>
}
/>
</FlexRow>
}
/>
<ConditionallyRender
condition={
hasParentDependency && !feature.dependencies[0]?.enabled
}
show={
<FlexRow>
<StyledDetail>
<StyledLabel>Dependency value:</StyledLabel>
<span>disabled</span>
</StyledDetail>
</FlexRow>
}
/>
<ConditionallyRender
condition={
hasParentDependency &&
Boolean(feature.dependencies[0]?.variants?.length)
}
show={
<FlexRow>
<StyledDetail>
<StyledLabel>Dependency value:</StyledLabel>
<VariantsTooltip
variants={
feature.dependencies[0]?.variants || []
}
/>
</StyledDetail>
</FlexRow>
}
/>
<ConditionallyRender
condition={hasChildren}
show={
<FlexRow>
<StyledDetail>
<StyledLabel>Children:</StyledLabel>
<ChildrenTooltip
childFeatures={feature.children}
project={feature.project}
/>
</StyledDetail>
</FlexRow>
}
/>
<ConditionallyRender
condition={Boolean(feature.project)}
show={
<AddDependencyDialogue
project={feature.project}
featureId={feature.name}
parentDependency={feature.dependencies[0]}
onClose={() => setShowDependencyDialogue(false)}
showDependencyDialogue={showDependencyDialogue}
/>
}
/>
</>
);
};

View File

@ -1,281 +0,0 @@
import { Box, capitalize, styled } from '@mui/material';
import { Link, useNavigate } from 'react-router-dom';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Edit from '@mui/icons-material/Edit';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { useState } from 'react';
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
import { StyledDetail } from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow';
import { formatDateYMD } from 'utils/formatDate';
import { parseISO } from 'date-fns';
import { FeatureEnvironmentSeen } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen';
import { OldDependencyRow } from './OldDependencyRow';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { useShowDependentFeatures } from './useShowDependentFeatures';
import type { ILastSeenEnvironments } from 'interfaces/featureToggle';
import { FeatureLifecycle } from '../FeatureLifecycle/FeatureLifecycle';
import { MarkCompletedDialogue } from '../FeatureLifecycle/MarkCompletedDialogue';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
const StyledContainer = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,
display: 'flex',
flexDirection: 'column',
maxWidth: '350px',
minWidth: '350px',
marginRight: theme.spacing(2),
[theme.breakpoints.down(1000)]: {
width: '100%',
maxWidth: 'none',
minWidth: 'auto',
},
}));
const StyledPaddingContainerTop = styled('div')({
padding: '1.5rem 1.5rem 0 1.5rem',
});
const StyledMetaDataHeader = styled('div')({
display: 'flex',
alignItems: 'center',
});
const StyledHeader = styled('h2')(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
fontWeight: 'normal',
margin: 0,
}));
const StyledBody = styled('div')(({ theme }) => ({
margin: theme.spacing(2, 0),
display: 'flex',
flexDirection: 'column',
fontSize: theme.fontSizes.smallBody,
}));
const BodyItemWithIcon = styled('div')(({ theme }) => ({}));
const SpacedBodyItem = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 0),
}));
const StyledDescriptionContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}));
const StyledDetailsContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}));
const StyledDescription = styled('p')({
wordBreak: 'break-word',
});
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
margin: theme.spacing(1),
}));
export const StyledLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
marginRight: theme.spacing(1),
}));
const OldFeatureOverviewMetaData = () => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature } = useFeature(projectId, featureId);
const { project, description, type } = feature;
const navigate = useNavigate();
const [showDelDialog, setShowDelDialog] = useState(false);
const [showMarkCompletedDialogue, setShowMarkCompletedDialogue] =
useState(false);
const { locationSettings } = useLocationSettings();
const showDependentFeatures = useShowDependentFeatures(feature.project);
const lastSeenEnvironments: ILastSeenEnvironments[] =
feature.environments?.map((env) => ({
name: env.name,
lastSeenAt: env.lastSeenAt,
enabled: env.enabled,
yes: env.yes,
no: env.no,
}));
const IconComponent = getFeatureTypeIcons(type);
return (
<StyledContainer>
<StyledPaddingContainerTop>
<StyledMetaDataHeader data-loading>
<IconComponent
sx={(theme) => ({
marginRight: theme.spacing(2),
height: '40px',
width: '40px',
padding: theme.spacing(0.5),
backgroundColor:
theme.palette.background.alternative,
fill: theme.palette.primary.contrastText,
borderRadius: `${theme.shape.borderRadiusMedium}px`,
})}
/>{' '}
<StyledHeader>{capitalize(type || '')} toggle</StyledHeader>
</StyledMetaDataHeader>
<StyledBody>
<SpacedBodyItem data-loading>
<StyledLabel>Project:</StyledLabel>
<Box sx={{ wordBreak: 'break-all' }}>{project}</Box>
</SpacedBodyItem>
<ConditionallyRender
condition={Boolean(feature.lifecycle)}
show={
<SpacedBodyItem data-loading>
<StyledLabel>Lifecycle:</StyledLabel>
<FeatureLifecycle
feature={feature}
onArchive={() => setShowDelDialog(true)}
onComplete={() =>
setShowMarkCompletedDialogue(true)
}
onUncomplete={refetchFeature}
/>
</SpacedBodyItem>
}
/>
<ConditionallyRender
condition={Boolean(description)}
show={
<BodyItemWithIcon data-loading sx={{ pt: 1 }}>
<StyledLabel>Description:</StyledLabel>
<StyledDescriptionContainer>
<StyledDescription>
{description}
</StyledDescription>
<PermissionIconButton
size='medium'
projectId={projectId}
permission={UPDATE_FEATURE}
component={Link}
to={`/projects/${projectId}/features/${featureId}/settings`}
tooltipProps={{
title: 'Edit description',
}}
>
<Edit />
</PermissionIconButton>
</StyledDescriptionContainer>
</BodyItemWithIcon>
}
elseShow={
<div data-loading>
<StyledDescriptionContainer>
No description.{' '}
<PermissionIconButton
size='medium'
projectId={projectId}
permission={UPDATE_FEATURE}
component={Link}
to={`/projects/${projectId}/features/${featureId}/settings`}
tooltipProps={{
title: 'Edit description',
}}
>
<Edit />
</PermissionIconButton>
</StyledDescriptionContainer>
</div>
}
/>
<BodyItemWithIcon>
<StyledDetailsContainer>
<StyledDetail>
<StyledLabel>Created at:</StyledLabel>
<span>
{formatDateYMD(
parseISO(feature.createdAt),
locationSettings.locale,
)}
</span>
</StyledDetail>
<FeatureEnvironmentSeen
featureLastSeen={feature.lastSeenAt}
environments={lastSeenEnvironments}
/>
</StyledDetailsContainer>
</BodyItemWithIcon>
<ConditionallyRender
condition={Boolean(feature.createdBy)}
show={() => (
<BodyItemWithIcon>
<StyledDetailsContainer>
<StyledDetail>
<StyledLabel>Created by:</StyledLabel>
<span>{feature.createdBy?.name}</span>
</StyledDetail>
<StyledUserAvatar
user={feature.createdBy}
/>
</StyledDetailsContainer>
</BodyItemWithIcon>
)}
/>
<ConditionallyRender
condition={showDependentFeatures}
show={<OldDependencyRow feature={feature} />}
/>
</StyledBody>
</StyledPaddingContainerTop>
<ConditionallyRender
condition={feature.children.length > 0}
show={
<FeatureArchiveNotAllowedDialog
features={feature.children}
project={projectId}
isOpen={showDelDialog}
onClose={() => setShowDelDialog(false)}
/>
}
elseShow={
<FeatureArchiveDialog
isOpen={showDelDialog}
onConfirm={() => {
navigate(`/projects/${projectId}`);
}}
onClose={() => setShowDelDialog(false)}
projectId={projectId}
featureIds={[featureId]}
/>
}
/>
<ConditionallyRender
condition={Boolean(feature.project)}
show={
<MarkCompletedDialogue
isOpen={showMarkCompletedDialogue}
setIsOpen={setShowMarkCompletedDialogue}
projectId={feature.project}
featureId={feature.name}
onComplete={refetchFeature}
/>
}
/>
</StyledContainer>
);
};
export default OldFeatureOverviewMetaData;

View File

@ -1,84 +0,0 @@
import { Box, styled } from '@mui/material';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { Sticky } from 'component/common/Sticky/Sticky';
import {
type ITab,
VerticalTabs,
} from 'component/common/VerticalTabs/VerticalTabs';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import { useEffect } from 'react';
const StyledContainer = styled(Box)(({ theme }) => ({
margin: theme.spacing(2),
marginLeft: 0,
padding: theme.spacing(3),
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '350px',
[theme.breakpoints.down('md')]: {
width: '100%',
},
}));
const StyledHeader = styled('h3')(({ theme }) => ({
display: 'flex',
fontSize: theme.fontSizes.bodySize,
margin: 0,
marginBottom: theme.spacing(1),
}));
const StyledVerticalTabs = styled(VerticalTabs)(({ theme }) => ({
'&&& .selected': {
backgroundColor: theme.palette.neutral.light,
},
}));
interface IFeatureOverviewSidePanelProps {
environmentId: string;
setEnvironmentId: React.Dispatch<React.SetStateAction<string>>;
}
export const FeatureOverviewSidePanel = ({
environmentId,
setEnvironmentId,
}: IFeatureOverviewSidePanelProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
const isSticky = feature.environments?.length <= 3;
const tabs: ITab[] = feature.environments.map(
({ name, enabled, strategies }) => ({
id: name,
label: name,
description:
strategies.length === 1
? '1 strategy'
: `${strategies.length || 'No'} strategies`,
startIcon: <EnvironmentIcon enabled={enabled} />,
}),
);
useEffect(() => {
if (!environmentId) {
setEnvironmentId(tabs[0]?.id);
}
}, [tabs]);
return (
<StyledContainer as={isSticky ? Sticky : Box}>
<StyledHeader data-loading>
Environments ({feature.environments.length})
</StyledHeader>
<StyledVerticalTabs
tabs={tabs}
value={environmentId}
onChange={({ id }) => setEnvironmentId(id)}
/>
</StyledContainer>
);
};

View File

@ -1,50 +0,0 @@
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { IconButton, styled } from '@mui/material';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledVisibilityToggle = styled(IconButton, {
shouldForwardProp: (prop) => prop !== 'visibilityOff',
})<{ visibilityOff: boolean }>(({ theme, visibilityOff }) => ({
marginLeft: 'auto',
marginRight: theme.spacing(-1),
color: visibilityOff
? theme.palette.action.active
: theme.palette.action.focus,
'&:hover': {
color: theme.palette.action.active,
},
}));
interface IFeatureOverviewSidePanelEnvironmentHiderProps {
environment: IFeatureEnvironment;
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
}
export const FeatureOverviewSidePanelEnvironmentHider = ({
environment,
hiddenEnvironments,
setHiddenEnvironments,
}: IFeatureOverviewSidePanelEnvironmentHiderProps) => {
const toggleHiddenEnvironments = () => {
setHiddenEnvironments(environment.name);
};
const isHidden = hiddenEnvironments.has(environment.name);
return (
<StyledVisibilityToggle
onClick={toggleHiddenEnvironments}
visibilityOff={isHidden}
aria-label={`${isHidden ? 'Show' : 'Hide'} environment "${environment.name}"`}
>
<ConditionallyRender
condition={isHidden}
show={<VisibilityOff />}
elseShow={<Visibility />}
/>
</StyledVisibilityToggle>
);
};

View File

@ -1,101 +0,0 @@
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { styled } from '@mui/material';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { FeatureOverviewSidePanelEnvironmentHider } from './FeatureOverviewSidePanelEnvironmentHider';
import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch';
import { useFeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
const StyledContainer = styled('div')(({ theme }) => ({
marginLeft: theme.spacing(-1.5),
'&:not(:last-of-type)': {
marginBottom: theme.spacing(2),
},
display: 'flex',
alignItems: 'center',
}));
const StyledLabel = styled('label')(() => ({
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer',
}));
interface IFeatureOverviewSidePanelEnvironmentSwitchProps {
environment: IFeatureEnvironment;
callback?: () => void;
children?: React.ReactNode;
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
}
export const FeatureOverviewSidePanelEnvironmentSwitch = ({
environment,
callback,
children,
hiddenEnvironments,
setHiddenEnvironments,
}: IFeatureOverviewSidePanelEnvironmentSwitchProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature } = useFeature(projectId, featureId);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const defaultContent = (
<>
{' '}
<span data-loading>
{environment.enabled ? 'enabled' : 'disabled'} in
</span>
&nbsp;
<StringTruncator
text={environment.name}
maxWidth='120'
maxLength={15}
/>
</>
);
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
useFeatureToggleSwitch(projectId);
const handleToggle = (newState: boolean, onRollback: () => void) =>
onFeatureToggle(newState, {
projectId,
featureId,
environmentName: environment.name,
environmentType: environment.type,
hasStrategies: environment.strategies.length > 0,
hasEnabledStrategies: environment.strategies.some(
(strategy) => !strategy.disabled,
),
isChangeRequestEnabled: isChangeRequestConfigured(environment.name),
onRollback,
onSuccess: () => {
if (callback) callback();
refetchFeature();
},
});
return (
<StyledContainer>
<StyledLabel>
<FeatureToggleSwitch
featureId={feature.name}
projectId={projectId}
environmentName={environment.name}
onToggle={handleToggle}
value={environment.enabled}
/>
{children ?? defaultContent}
</StyledLabel>
<FeatureOverviewSidePanelEnvironmentHider
environment={environment}
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
/>
{featureToggleModals}
</StyledContainer>
);
};

View File

@ -1,117 +0,0 @@
import type { IFeatureToggle } from 'interfaces/featureToggle';
import { FeatureOverviewSidePanelEnvironmentSwitch } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch';
import { Link, styled, Tooltip } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
const StyledContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(3),
}));
const StyledSwitchLabel = styled('div')(() => ({
display: 'flex',
flexDirection: 'column',
}));
const StyledLabel = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
}));
const StyledSubLabel = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
display: 'flex',
alignItems: 'center',
}));
const StyledSeparator = styled('span')(({ theme }) => ({
padding: theme.spacing(0, 0.5),
'::after': {
content: '"-"',
},
}));
const StyledLink = styled(Link<typeof RouterLink | 'a'>)(() => ({
'&:hover, &:focus': {
textDecoration: 'underline',
},
}));
interface IFeatureOverviewSidePanelEnvironmentSwitchesProps {
feature: IFeatureToggle;
header: React.ReactNode;
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
}
export const FeatureOverviewSidePanelEnvironmentSwitches = ({
feature,
header,
hiddenEnvironments,
setHiddenEnvironments,
}: IFeatureOverviewSidePanelEnvironmentSwitchesProps) => {
const someEnabledEnvironmentHasVariants = feature.environments.some(
(environment) => environment.enabled && environment.variants?.length,
);
return (
<StyledContainer data-testid='feature-flag-status'>
{header}
{feature.environments.map((environment) => {
const strategiesLabel =
environment.strategies.length === 1
? '1 strategy'
: `${environment.strategies.length} strategies`;
const variants = environment.variants ?? [];
const variantsLink = variants.length > 0 && (
<>
<StyledSeparator />
<Tooltip title='View variants' arrow describeChild>
<StyledLink
component={RouterLink}
to={`/projects/${feature.project}/features/${feature.name}/variants`}
underline='hover'
>
{variants.length === 1
? '1 variant'
: `${variants.length} variants`}
</StyledLink>
</Tooltip>
</>
);
const hasWarning =
environment.enabled &&
variants.length === 0 &&
someEnabledEnvironmentHasVariants;
return (
<FeatureOverviewSidePanelEnvironmentSwitch
key={environment.name}
environment={environment}
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
>
<StyledSwitchLabel>
<StyledLabel>{environment.name}</StyledLabel>
<StyledSubLabel>
{strategiesLabel}
{variantsLink}
<ConditionallyRender
condition={hasWarning}
show={
<>
<StyledSeparator />
<VariantsWarningTooltip />
</>
}
/>
</StyledSubLabel>
</StyledSwitchLabel>
</FeatureOverviewSidePanelEnvironmentSwitch>
);
})}
</StyledContainer>
);
};

View File

@ -1,89 +0,0 @@
import { Box, Divider, styled } from '@mui/material';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches';
import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags';
import { Sticky } from 'component/common/Sticky/Sticky';
const StyledContainer = styled(Box)(({ theme }) => ({
top: theme.spacing(2),
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,
display: 'flex',
flexDirection: 'column',
maxWidth: '350px',
minWidth: '350px',
marginRight: '1rem',
marginTop: '1rem',
[theme.breakpoints.down(1000)]: {
marginBottom: '1rem',
width: '100%',
maxWidth: 'none',
minWidth: 'auto',
},
}));
const StyledHeader = styled('h3')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
fontSize: theme.fontSizes.bodySize,
margin: 0,
marginBottom: theme.spacing(3),
// Make the help icon align with the text.
'& > :last-child': {
position: 'relative',
top: 1,
},
}));
interface IFeatureOverviewSidePanelProps {
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
}
export const OldFeatureOverviewSidePanel = ({
hiddenEnvironments,
setHiddenEnvironments,
}: IFeatureOverviewSidePanelProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
const isSticky = feature.environments?.length <= 3;
return (
<StyledContainer as={isSticky ? Sticky : Box}>
<FeatureOverviewSidePanelEnvironmentSwitches
header={
<StyledHeader data-loading>
Enabled in environments (
{
feature.environments.filter(
({ enabled }) => enabled,
).length
}
)
<HelpIcon
tooltip='When a feature is switched off in an environment, it will always return false. When switched on, it will return true or false depending on its strategies.'
placement='top'
/>
</StyledHeader>
}
feature={feature}
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
/>
<Divider />
<FeatureOverviewSidePanelTags
header={
<StyledHeader data-loading>
Tags for this feature flag
</StyledHeader>
}
feature={feature}
/>
</StyledContainer>
);
};

View File

@ -1,91 +0,0 @@
import { FeatureOverviewEnvironments } from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import {
FeatureStrategyEdit,
formatFeaturePath,
} from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { usePageTitle } from 'hooks/usePageTitle';
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
import { styled } from '@mui/material';
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { useEffect } from 'react';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
import OldFeatureOverviewMetaData from './FeatureOverviewMetaData/OldFeatureOverviewMetaData';
import { OldFeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel';
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
width: '100%',
[theme.breakpoints.down(1000)]: {
flexDirection: 'column',
},
}));
const StyledMainContent = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
width: `calc(100% - (350px + 1rem))`,
[theme.breakpoints.down(1000)]: {
width: '100%',
},
}));
const FeatureOverview = () => {
const navigate = useNavigate();
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const featurePath = formatFeaturePath(projectId, featureId);
const { hiddenEnvironments, setHiddenEnvironments } =
useHiddenEnvironments();
const onSidebarClose = () => navigate(featurePath);
usePageTitle(featureId);
const { setLastViewed } = useLastViewedFlags();
useEffect(() => {
setLastViewed({ featureId, projectId });
}, [featureId]);
return (
<StyledContainer>
<div>
<OldFeatureOverviewMetaData />
<OldFeatureOverviewSidePanel
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
/>
</div>
<StyledMainContent>
<FeatureOverviewEnvironments />
</StyledMainContent>
<Routes>
<Route
path='strategies/create'
element={
<SidebarModal
label='Create feature strategy'
onClose={onSidebarClose}
open
>
<FeatureStrategyCreate />
</SidebarModal>
}
/>
<Route
path='strategies/edit'
element={
<SidebarModal
label='Edit feature strategy'
onClose={onSidebarClose}
open
>
<FeatureStrategyEdit />
</SidebarModal>
}
/>
</Routes>
</StyledContainer>
);
};
export default FeatureOverview;

View File

@ -1,316 +0,0 @@
import Delete from '@mui/icons-material/Delete';
import { styled } from '@mui/material';
import { DELETE_FEATURE_STRATEGY } from '@server/types/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useToast from 'hooks/useToast';
import type {
IReleasePlan,
IReleasePlanMilestone,
} from 'interfaces/releasePlans';
import { useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ReleasePlanRemoveDialog } from './ReleasePlanRemoveDialog';
import { ReleasePlanMilestone } from './ReleasePlanMilestone/LegacyReleasePlanMilestone';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useUiFlag } from 'hooks/useUiFlag';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { RemoveReleasePlanChangeRequestDialog } from './ChangeRequest/RemoveReleasePlanChangeRequestDialog';
import { StartMilestoneChangeRequestDialog } from './ChangeRequest/StartMilestoneChangeRequestDialog';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { Truncator } from 'component/common/Truncator/Truncator';
const StyledContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'readonly',
})<{ readonly?: boolean }>(({ theme, readonly }) => ({
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusMedium,
'& + &': {
marginTop: theme.spacing(2),
},
background: readonly
? theme.palette.background.elevation1
: theme.palette.background.paper,
}));
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
color: theme.palette.text.primary,
}));
const StyledHeaderTitleContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: theme.spacing(1),
}));
const StyledHeaderTitleLabel = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
lineHeight: 0.5,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(0.5),
}));
const StyledHeaderDescription = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledBody = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(3),
}));
const StyledConnection = styled('div')(({ theme }) => ({
width: 4,
height: theme.spacing(2),
backgroundColor: theme.palette.divider,
marginLeft: theme.spacing(3.25),
}));
interface IReleasePlanProps {
plan: IReleasePlan;
environmentIsDisabled?: boolean;
readonly?: boolean;
}
export const ReleasePlan = ({
plan,
environmentIsDisabled,
readonly,
}: IReleasePlanProps) => {
const {
id,
name,
description,
activeMilestoneId,
featureName,
environment,
milestones,
} = plan;
const projectId = useRequiredPathParam('projectId');
const { refetch } = useReleasePlans(projectId, featureName, environment);
const { removeReleasePlanFromFeature, startReleasePlanMilestone } =
useReleasePlansApi();
const { setToastData, setToastApiError } = useToast();
const { trackEvent } = usePlausibleTracker();
const [removeOpen, setRemoveOpen] = useState(false);
const [changeRequestDialogRemoveOpen, setChangeRequestDialogRemoveOpen] =
useState(false);
const [
changeRequestDialogStartMilestoneOpen,
setChangeRequestDialogStartMilestoneOpen,
] = useState(false);
const [
milestoneForChangeRequestDialog,
setMilestoneForChangeRequestDialog,
] = useState<IReleasePlanMilestone>();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const releasePlansEnabled = useUiFlag('releasePlans');
const onAddRemovePlanChangesConfirm = async () => {
await addChange(projectId, environment, {
feature: featureName,
action: 'deleteReleasePlan',
payload: {
planId: plan.id,
},
});
await refetchChangeRequests();
setToastData({
type: 'success',
text: 'Added to draft',
});
setChangeRequestDialogRemoveOpen(false);
};
const onAddStartMilestoneChangesConfirm = async () => {
await addChange(projectId, environment, {
feature: featureName,
action: 'startMilestone',
payload: {
planId: plan.id,
milestoneId: milestoneForChangeRequestDialog?.id,
},
});
await refetchChangeRequests();
setToastData({
type: 'success',
text: 'Added to draft',
});
setChangeRequestDialogStartMilestoneOpen(false);
};
const confirmRemoveReleasePlan = () => {
if (releasePlansEnabled && isChangeRequestConfigured(environment)) {
setChangeRequestDialogRemoveOpen(true);
} else {
setRemoveOpen(true);
}
trackEvent('release-management', {
props: {
eventType: 'remove-plan',
plan: name,
},
});
};
const onRemoveConfirm = async () => {
try {
await removeReleasePlanFromFeature(
projectId,
featureName,
environment,
id,
);
setToastData({
text: `Release plan "${name}" has been removed from ${featureName} in ${environment}`,
type: 'success',
});
refetch();
setRemoveOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onStartMilestone = async (milestone: IReleasePlanMilestone) => {
if (releasePlansEnabled && isChangeRequestConfigured(environment)) {
setMilestoneForChangeRequestDialog(milestone);
setChangeRequestDialogStartMilestoneOpen(true);
} else {
try {
await startReleasePlanMilestone(
projectId,
featureName,
environment,
id,
milestone.id,
);
setToastData({
text: `Milestone "${milestone.name}" has started`,
type: 'success',
});
refetch();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
trackEvent('release-management', {
props: {
eventType: 'start-milestone',
plan: name,
milestone: milestone.name,
},
});
};
const activeIndex = milestones.findIndex(
(milestone) => milestone.id === activeMilestoneId,
);
return (
<StyledContainer readonly={readonly}>
<StyledHeader>
<StyledHeaderTitleContainer>
<StyledHeaderTitleLabel>
Release plan
</StyledHeaderTitleLabel>
<span>{name}</span>
<StyledHeaderDescription>
<Truncator lines={2} title={description}>
{description}
</Truncator>
</StyledHeaderDescription>
</StyledHeaderTitleContainer>
{!readonly && (
<PermissionIconButton
onClick={confirmRemoveReleasePlan}
permission={DELETE_FEATURE_STRATEGY}
environmentId={environment}
projectId={projectId}
tooltipProps={{
title: 'Remove release plan',
}}
>
<Delete />
</PermissionIconButton>
)}
</StyledHeader>
<StyledBody>
{milestones.map((milestone, index) => (
<div key={milestone.id}>
<ReleasePlanMilestone
readonly={readonly}
milestone={milestone}
status={
milestone.id === activeMilestoneId
? environmentIsDisabled
? 'paused'
: 'active'
: index < activeIndex
? 'completed'
: 'not-started'
}
onStartMilestone={onStartMilestone}
/>
<ConditionallyRender
condition={index < milestones.length - 1}
show={<StyledConnection />}
/>
</div>
))}
</StyledBody>
<ReleasePlanRemoveDialog
plan={plan}
open={removeOpen}
setOpen={setRemoveOpen}
onConfirm={onRemoveConfirm}
environmentActive={!environmentIsDisabled}
/>
<RemoveReleasePlanChangeRequestDialog
environmentId={environment}
featureId={featureName}
isOpen={changeRequestDialogRemoveOpen}
onConfirm={onAddRemovePlanChangesConfirm}
onClosing={() => setChangeRequestDialogRemoveOpen(false)}
releasePlan={plan}
environmentActive={!environmentIsDisabled}
/>
<StartMilestoneChangeRequestDialog
environmentId={environment}
featureId={featureName}
isOpen={changeRequestDialogStartMilestoneOpen}
onConfirm={onAddStartMilestoneChangesConfirm}
onClosing={() => {
setMilestoneForChangeRequestDialog(undefined);
setChangeRequestDialogStartMilestoneOpen(false);
}}
releasePlan={plan}
milestone={milestoneForChangeRequestDialog}
/>
</StyledContainer>
);
};

View File

@ -1,130 +0,0 @@
// deprecated; remove with `flagOverviewRedesign` flag
import ExpandMore from '@mui/icons-material/ExpandMore';
import {
Accordion,
AccordionDetails,
AccordionSummary,
styled,
} from '@mui/material';
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ReleasePlanMilestoneStrategy } from './ReleasePlanMilestoneStrategy';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import {
ReleasePlanMilestoneStatus,
type MilestoneStatus,
} from './ReleasePlanMilestoneStatus';
import { useState } from 'react';
const StyledAccordion = styled(Accordion, {
shouldForwardProp: (prop) => prop !== 'status',
})<{ status: MilestoneStatus }>(({ theme, status }) => ({
border: `1px solid ${status === 'active' ? theme.palette.success.border : theme.palette.divider}`,
boxShadow: 'none',
margin: 0,
backgroundColor: theme.palette.background.paper,
'&:before': {
display: 'none',
},
}));
const StyledAccordionSummary = styled(AccordionSummary)({
'& .MuiAccordionSummary-content': {
justifyContent: 'space-between',
alignItems: 'center',
minHeight: '30px',
},
});
const StyledTitleContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'start',
flexDirection: 'column',
gap: theme.spacing(0.5),
}));
const StyledTitle = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
}));
const StyledSecondaryLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
}));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
}));
interface IReleasePlanMilestoneProps {
milestone: IReleasePlanMilestone;
status?: MilestoneStatus;
onStartMilestone?: (milestone: IReleasePlanMilestone) => void;
readonly?: boolean;
}
export const ReleasePlanMilestone = ({
milestone,
status = 'not-started',
onStartMilestone,
readonly,
}: IReleasePlanMilestoneProps) => {
const [expanded, setExpanded] = useState(false);
if (!milestone.strategies.length) {
return (
<StyledAccordion status={status}>
<StyledAccordionSummary>
<StyledTitleContainer>
<StyledTitle>{milestone.name}</StyledTitle>
{!readonly && onStartMilestone && (
<ReleasePlanMilestoneStatus
status={status}
onStartMilestone={() =>
onStartMilestone(milestone)
}
/>
)}
</StyledTitleContainer>
<StyledSecondaryLabel>No strategies</StyledSecondaryLabel>
</StyledAccordionSummary>
</StyledAccordion>
);
}
return (
<StyledAccordion
status={status}
onChange={(evt, expanded) => setExpanded(expanded)}
>
<StyledAccordionSummary expandIcon={<ExpandMore />}>
<StyledTitleContainer>
<StyledTitle>{milestone.name}</StyledTitle>
{!readonly && onStartMilestone && (
<ReleasePlanMilestoneStatus
status={status}
onStartMilestone={() => onStartMilestone(milestone)}
/>
)}
</StyledTitleContainer>
<StyledSecondaryLabel>
{milestone.strategies.length === 1
? `${expanded ? 'Hide' : 'View'} strategy`
: `${expanded ? 'Hide' : 'View'} ${milestone.strategies.length} strategies`}
</StyledSecondaryLabel>
</StyledAccordionSummary>
<StyledAccordionDetails>
{milestone.strategies.map((strategy, index) => (
<div key={strategy.id}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='OR' />}
/>
<ReleasePlanMilestoneStrategy strategy={strategy} />
</div>
))}
</StyledAccordionDetails>
</StyledAccordion>
);
};

View File

@ -5,19 +5,11 @@ import {
Tab,
Tabs,
type Theme,
Tooltip,
Typography,
useMediaQuery,
} from '@mui/material';
import Archive from '@mui/icons-material/Archive';
import ArchiveOutlined from '@mui/icons-material/ArchiveOutlined';
import FileCopy from '@mui/icons-material/FileCopy';
import Label from '@mui/icons-material/Label';
import WatchLater from '@mui/icons-material/WatchLater';
import WatchLaterOutlined from '@mui/icons-material/WatchLaterOutlined';
import LibraryAdd from '@mui/icons-material/LibraryAdd';
import LibraryAddOutlined from '@mui/icons-material/LibraryAddOutlined';
import Check from '@mui/icons-material/Check';
import Star from '@mui/icons-material/Star';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
@ -27,17 +19,12 @@ import {
UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
import { ChildrenTooltip } from './FeatureOverview/FeatureOverviewMetaData/ChildrenTooltip';
import copy from 'copy-to-clipboard';
import useToast from 'hooks/useToast';
import { useUiFlag } from 'hooks/useUiFlag';
import type { IFeatureToggle } from 'interfaces/featureToggle';
import { Collaborators } from './Collaborators';
import StarBorder from '@mui/icons-material/StarBorder';
import { TooltipResolver } from 'component/common/TooltipResolver/TooltipResolver';
import { ManageTagsDialog } from './FeatureOverview/ManageTagsDialog/ManageTagsDialog';
@ -46,7 +33,7 @@ import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/Feat
import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog';
import { FeatureCopyName } from './FeatureCopyName/FeatureCopyName';
const NewStyledHeader = styled('div')(({ theme }) => ({
const StyledHeader = styled('div')(({ theme }) => ({
backgroundColor: 'none',
marginBottom: theme.spacing(2),
borderBottom: `1px solid ${theme.palette.divider}`,
@ -115,67 +102,6 @@ const IconButtonWithTooltip: FC<
);
};
const StyledHeader = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadiusLarge,
marginBottom: theme.spacing(2),
}));
const StyledInnerContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(2, 4, 2, 2),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
[theme.breakpoints.down(500)]: {
flexDirection: 'column',
},
}));
const StyledFlagInfoContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
columnGap: theme.spacing(1),
}));
const StyledDependency = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
fontSize: theme.fontSizes.smallBody,
padding: theme.spacing(0.75, 1.5),
backgroundColor: theme.palette.background.elevation2,
borderRadius: `${theme.shape.borderRadiusMedium}px`,
width: 'max-content',
}));
const StyledFeatureViewHeader = styled('h1')(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
fontWeight: 'normal',
display: 'flex',
alignItems: 'center',
wordBreak: 'break-all',
}));
const StyledToolbarContainer = styled('div')({
flexShrink: 0,
display: 'flex',
});
const StyledSeparator = styled('div')(({ theme }) => ({
width: '100%',
backgroundColor: theme.palette.divider,
height: '1px',
}));
const StyledTabRow = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'row nowrap',
gap: theme.spacing(4),
paddingInline: theme.spacing(4),
justifyContent: 'space-between',
}));
const StyledTabs = styled(Tabs)({
minWidth: 0,
maxWidth: '100%',
@ -280,7 +206,6 @@ type Props = {
export const FeatureViewHeader: FC<Props> = ({ feature }) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const { favorite, unfavorite } = useFavoriteFeaturesApi();
const { refetchFeature } = useFeature(projectId, featureId);
const { setToastData, setToastApiError } = useToast();
@ -289,9 +214,6 @@ export const FeatureViewHeader: FC<Props> = ({ feature }) => {
const [showDelDialog, setShowDelDialog] = useState(false);
const [openStaleDialog, setOpenStaleDialog] = useState(false);
const [isFeatureNameCopied, setIsFeatureNameCopied] = useState(false);
const smallScreen = useMediaQuery(`(max-width:${500}px)`);
const navigate = useNavigate();
const { pathname } = useLocation();
@ -344,21 +266,6 @@ export const FeatureViewHeader: FC<Props> = ({ feature }) => {
}
};
const handleCopyToClipboard = () => {
try {
copy(feature.name);
setIsFeatureNameCopied(true);
setTimeout(() => {
setIsFeatureNameCopied(false);
}, 3000);
} catch (error: unknown) {
setToastData({
type: 'error',
text: 'Could not copy feature name',
});
}
};
const HeaderActionsInner: FC<{ showOnNarrowScreens?: boolean }> = ({
showOnNarrowScreens,
}) => {
@ -375,184 +282,36 @@ export const FeatureViewHeader: FC<Props> = ({ feature }) => {
return (
<>
{flagOverviewRedesign ? (
<NewStyledHeader>
<UpperHeaderRow>
<StyledTitle>
<Typography variant='h1'>{feature.name}</Typography>
<FeatureCopyName name={feature.name} />
</StyledTitle>
{feature.stale ? (
<FeatureStatusChip stale={true} />
) : null}
</UpperHeaderRow>
<LowerHeaderRow>
<HeaderActionsInner showOnNarrowScreens />
<StyledTabs
value={activeTab.path}
indicatorColor='primary'
textColor='primary'
aria-label='Feature flag tabs'
variant='scrollable'
>
{tabData.map((tab) => (
<StyledTabButton
key={tab.title}
label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)}
data-testid={`TAB-${tab.title}`}
/>
))}
</StyledTabs>
<HeaderActionsInner />
</LowerHeaderRow>
</NewStyledHeader>
) : (
<StyledHeader>
<StyledInnerContainer>
<StyledFlagInfoContainer>
<FavoriteIconButton
onClick={onFavorite}
isFavorite={feature.favorite}
<StyledHeader>
<UpperHeaderRow>
<StyledTitle>
<Typography variant='h1'>{feature.name}</Typography>
<FeatureCopyName name={feature.name} />
</StyledTitle>
{feature.stale ? <FeatureStatusChip stale={true} /> : null}
</UpperHeaderRow>
<LowerHeaderRow>
<HeaderActionsInner showOnNarrowScreens />
<StyledTabs
value={activeTab.path}
indicatorColor='primary'
textColor='primary'
aria-label='Feature flag tabs'
variant='scrollable'
>
{tabData.map((tab) => (
<StyledTabButton
key={tab.title}
label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)}
data-testid={`TAB-${tab.title}`}
/>
<div>
<StyledFlagInfoContainer>
<StyledFeatureViewHeader data-loading>
{feature.name}
</StyledFeatureViewHeader>
<Tooltip
title={
isFeatureNameCopied
? 'Copied!'
: 'Copy name'
}
arrow
>
<IconButton
onClick={handleCopyToClipboard}
>
{isFeatureNameCopied ? (
<Check
style={{ fontSize: 16 }}
/>
) : (
<FileCopy
style={{ fontSize: 16 }}
/>
)}
</IconButton>
</Tooltip>
<ConditionallyRender
condition={!smallScreen}
show={
<FeatureStatusChip
stale={feature.stale}
/>
}
/>
</StyledFlagInfoContainer>
<ConditionallyRender
condition={feature.dependencies.length > 0}
show={
<StyledDependency>
<b>Has parent: </b>
<StyledLink
to={`/projects/${feature.project}/features/${feature.dependencies[0]?.feature}`}
>
{
feature.dependencies[0]
?.feature
}
</StyledLink>
</StyledDependency>
}
/>
<ConditionallyRender
condition={feature.children.length > 0}
show={
<StyledDependency>
<b>Has children:</b>
<ChildrenTooltip
childFeatures={feature.children}
project={feature.project}
/>
</StyledDependency>
}
/>
</div>
</StyledFlagInfoContainer>
<StyledToolbarContainer>
<PermissionIconButton
permission={CREATE_FEATURE}
projectId={projectId}
data-loading
component={Link}
to={`/projects/${projectId}/features/${featureId}/copy`}
tooltipProps={{
title: 'Clone',
}}
>
<LibraryAdd />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Archive feature flag',
}}
data-loading
onClick={() => setShowDelDialog(true)}
>
<Archive />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenStaleDialog(true)}
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Toggle stale state',
}}
data-loading
>
<WatchLater />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenTagDialog(true)}
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{ title: 'Add tag' }}
data-loading
>
<Label />
</PermissionIconButton>
</StyledToolbarContainer>
</StyledInnerContainer>
<StyledSeparator />
<StyledTabRow>
<Tabs
value={activeTab.path}
indicatorColor='primary'
textColor='primary'
>
{tabData.map((tab) => (
<StyledTabButton
key={tab.title}
label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)}
data-testid={`TAB-${tab.title}`}
/>
))}
</Tabs>
<Collaborators
collaborators={feature.collaborators?.users}
/>
</StyledTabRow>
</StyledHeader>
)}
))}
</StyledTabs>
<HeaderActionsInner />
</LowerHeaderRow>
</StyledHeader>
{feature.children.length > 0 ? (
<FeatureArchiveNotAllowedDialog
features={feature.children}
@ -571,7 +330,6 @@ export const FeatureViewHeader: FC<Props> = ({ feature }) => {
featureIds={[featureId]}
/>
)}
<FeatureStaleDialog
isStale={feature.stale}
isOpen={openStaleDialog}

View File

@ -1,6 +1,5 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { FeatureDetails as LegacyFeatureDetails } from './LegacyFeatureDetails';
import type { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { FeatureDetails } from './FeatureDetails';
@ -80,7 +79,7 @@ const testCases = [
testCases.forEach(({ name, feature, expectedText1, expectedText2 }) => {
test(`${name} (legacy)`, async () => {
render(
<LegacyFeatureDetails
<FeatureDetails
feature={feature}
input={
{ environment: 'development' } as PlaygroundRequestSchema

View File

@ -2,9 +2,6 @@ import { useRef, useState } from 'react';
import type { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { IconButton, Popover, styled } from '@mui/material';
import InfoOutlined from '@mui/icons-material/InfoOutlined';
import { FeatureDetails as LegacyFeatureDetails } from './FeatureDetails/LegacyFeatureDetails';
import { PlaygroundResultFeatureStrategyList as LegacyPlaygroundResultFeatureStrategyList } from './FeatureStrategyList/LegacyPlaygroundResultFeatureStrategyList';
import { useUiFlag } from 'hooks/useUiFlag';
import { FeatureDetails } from './FeatureDetails/FeatureDetails';
import { PlaygroundResultFeatureStrategyList } from './FeatureStrategyList/PlaygroundResultsFeatureStrategyList';
@ -18,62 +15,6 @@ const FeatureResultPopoverWrapper = styled('div')(({ theme }) => ({
color: theme.palette.divider,
}));
const LegacyFeatureResultInfoPopoverCell = ({
feature,
input,
}: FeatureResultInfoPopoverCellProps) => {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const togglePopover = () => {
setOpen(!open);
};
return (
<FeatureResultPopoverWrapper>
<IconButton onClick={togglePopover}>
<InfoOutlined ref={ref} />
</IconButton>
<Popover
open={open}
onClose={() => setOpen(false)}
anchorEl={ref.current}
PaperProps={{
sx: (theme) => ({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(6),
width: 728,
maxWidth: '100%',
height: 'auto',
overflowY: 'auto',
backgroundColor: theme.palette.background.elevation2,
borderRadius: theme.shape.borderRadius,
}),
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
}}
>
<LegacyFeatureDetails
feature={feature}
input={input}
onClose={() => setOpen(false)}
/>
<LegacyPlaygroundResultFeatureStrategyList
feature={feature}
input={input}
/>
</Popover>
</FeatureResultPopoverWrapper>
);
};
export const NewFeatureResultInfoPopoverCell = ({
feature,
input,
@ -136,15 +77,9 @@ export const NewFeatureResultInfoPopoverCell = ({
export const FeatureResultInfoPopoverCell = (
props: FeatureResultInfoPopoverCellProps,
) => {
const useNewStrategyDesign = useUiFlag('flagOverviewRedesign');
if (!props.feature) {
return null;
}
return useNewStrategyDesign ? (
<NewFeatureResultInfoPopoverCell {...props} />
) : (
<LegacyFeatureResultInfoPopoverCell {...props} />
);
return <NewFeatureResultInfoPopoverCell {...props} />;
};

View File

@ -1,77 +0,0 @@
import {
PlaygroundResultStrategyLists,
WrappedPlaygroundResultStrategyList,
} from './StrategyList/LegacyPlaygroundResultStrategyLists';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { Alert } from '@mui/material';
interface PlaygroundResultFeatureStrategyListProps {
feature: PlaygroundFeatureSchema;
input?: PlaygroundRequestSchema;
}
export const PlaygroundResultFeatureStrategyList = ({
feature,
input,
}: PlaygroundResultFeatureStrategyListProps) => {
const enabledStrategies = feature.strategies?.data?.filter(
(strategy) => !strategy.disabled,
);
const disabledStrategies = feature.strategies?.data?.filter(
(strategy) => strategy.disabled,
);
const showDisabledStrategies = disabledStrategies?.length > 0;
return (
<>
<ConditionallyRender
condition={feature?.strategies?.data?.length === 0}
show={
<Alert severity='warning' sx={{ mt: 2 }}>
There are no strategies added to this feature flag in
selected environment.
</Alert>
}
/>
<ConditionallyRender
condition={
(feature.hasUnsatisfiedDependency ||
!feature.isEnabledInCurrentEnvironment) &&
Boolean(feature?.strategies?.data)
}
show={
<WrappedPlaygroundResultStrategyList
feature={feature}
input={input}
/>
}
elseShow={
<>
<PlaygroundResultStrategyLists
strategies={enabledStrategies || []}
input={input}
titlePrefix={
showDisabledStrategies ? 'Enabled' : ''
}
/>
<ConditionallyRender
condition={showDisabledStrategies}
show={
<PlaygroundResultStrategyLists
strategies={disabledStrategies}
input={input}
titlePrefix={'Disabled'}
infoText={
'Disabled strategies are not evaluated for the overall result.'
}
/>
}
/>
</>
}
/>
</>
);
};

View File

@ -1,8 +1,7 @@
import { vi } from 'vitest';
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import type { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { PlaygroundResultFeatureStrategyList as LegacyPlaygroundResultFeatureStrategyList } from './LegacyPlaygroundResultFeatureStrategyList';
import { vi } from 'vitest';
import { PlaygroundResultFeatureStrategyList } from './PlaygroundResultsFeatureStrategyList';
const testCases = [
@ -139,7 +138,7 @@ afterAll(() => {
testCases.forEach(({ name, feature, expectedText }) => {
test(`${name} (legacy)`, async () => {
render(
<LegacyPlaygroundResultFeatureStrategyList
<PlaygroundResultFeatureStrategyList
feature={feature}
input={
{ environment: 'development' } as PlaygroundRequestSchema

View File

@ -1,152 +0,0 @@
import { Fragment } from 'react';
import { Alert, Box, styled, Typography } from '@mui/material';
import type {
PlaygroundStrategySchema,
PlaygroundRequestSchema,
PlaygroundFeatureSchema,
} from 'openapi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureStrategyItem } from './StrategyItem/LegacyFeatureStrategyItem';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
const StyledAlertWrapper = styled('div')(({ theme }) => ({
display: 'flex',
padding: `0, 4px`,
flexDirection: 'column',
borderRadius: theme.shape.borderRadiusMedium,
border: `1px solid ${theme.palette.warning.border}`,
}));
const StyledListWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(1, 0.5),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
border: '0!important',
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
borderBottom: `1px solid ${theme.palette.warning.border}!important`,
}));
interface PlaygroundResultStrategyListProps {
strategies: PlaygroundStrategySchema[];
input?: PlaygroundRequestSchema;
titlePrefix?: string;
infoText?: string;
}
const StyledSubtitle = styled(Typography)(({ theme }) => ({
margin: theme.spacing(2, 1, 2, 0),
color: 'text.secondary',
}));
export const PlaygroundResultStrategyLists = ({
strategies,
input,
titlePrefix,
infoText,
}: PlaygroundResultStrategyListProps) => (
<ConditionallyRender
condition={strategies.length > 0}
show={
<>
<StyledSubtitle variant={'subtitle1'}>{`${
titlePrefix
? titlePrefix.concat(' strategies')
: 'Strategies'
} (${strategies?.length})`}</StyledSubtitle>
<ConditionallyRender
condition={Boolean(infoText)}
show={
<StyledSubtitle variant={'subtitle2'}>
{infoText}
</StyledSubtitle>
}
/>
<Box sx={{ width: '100%' }}>
{strategies?.map((strategy, index) => (
<Fragment key={strategy.id}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='OR' />}
/>
<FeatureStrategyItem
key={strategy.id}
strategy={strategy}
index={index}
input={input}
/>
</Fragment>
))}
</Box>
</>
}
/>
);
interface IWrappedPlaygroundResultStrategyListProps {
feature: PlaygroundFeatureSchema;
input?: PlaygroundRequestSchema;
}
const resolveHintText = (feature: PlaygroundFeatureSchema) => {
if (
feature.hasUnsatisfiedDependency &&
!feature.isEnabledInCurrentEnvironment
) {
return 'If the environment was enabled and parent dependencies were satisfied';
}
if (feature.hasUnsatisfiedDependency) {
return 'If parent dependencies were satisfied';
}
if (!feature.isEnabledInCurrentEnvironment) {
return 'If the environment was enabled';
}
return '';
};
export const WrappedPlaygroundResultStrategyList = ({
feature,
input,
}: IWrappedPlaygroundResultStrategyListProps) => {
const enabledStrategies = feature.strategies?.data?.filter(
(strategy) => !strategy.disabled,
);
const disabledStrategies = feature.strategies?.data?.filter(
(strategy) => strategy.disabled,
);
const showDisabledStrategies = disabledStrategies?.length > 0;
return (
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
<StyledAlert severity={'info'} color={'warning'}>
{resolveHintText(feature)}, then this feature flag would be{' '}
{feature.strategies?.result ? 'TRUE' : 'FALSE'} with strategies
evaluated like this:{' '}
</StyledAlert>
<StyledListWrapper sx={{ p: 2.5 }}>
<PlaygroundResultStrategyLists
strategies={enabledStrategies || []}
input={input}
titlePrefix={showDisabledStrategies ? 'Enabled' : ''}
/>
</StyledListWrapper>
<ConditionallyRender
condition={showDisabledStrategies}
show={
<StyledListWrapper sx={{ p: 2.5 }}>
<PlaygroundResultStrategyLists
strategies={disabledStrategies}
input={input}
titlePrefix={'Disabled'}
infoText={
'Disabled strategies are not evaluated for the overall result.'
}
/>
</StyledListWrapper>
}
/>
</StyledAlertWrapper>
);
};

View File

@ -1,70 +0,0 @@
import { useTheme } from '@mui/material';
import { PlaygroundResultChip } from '../../../../PlaygroundResultChip/LegacyPlaygroundResultChip';
import type {
PlaygroundStrategySchema,
PlaygroundRequestSchema,
} from 'openapi';
import { StrategyExecution } from './PlaygroundStrategyExecution/LegacyStrategyExecution';
import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DisabledStrategyExecution } from './PlaygroundStrategyExecution/DisabledStrategyExecution';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/LegacyStrategyItemContainer';
interface IFeatureStrategyItemProps {
strategy: PlaygroundStrategySchema;
index: number;
input?: PlaygroundRequestSchema;
}
export const FeatureStrategyItem = ({
strategy,
input,
index,
}: IFeatureStrategyItemProps) => {
const { result } = strategy;
const theme = useTheme();
const label =
result.evaluationStatus === 'incomplete' ||
result.evaluationStatus === 'unevaluated'
? 'Unevaluated'
: result.enabled
? 'True'
: 'False';
return (
<StrategyItemContainer
style={{
borderColor:
result.enabled && result.evaluationStatus === 'complete'
? theme.palette.success.main
: 'none',
}}
strategy={{ ...strategy, id: `${objectId(strategy)}` }}
orderNumber={index + 1}
actions={
<PlaygroundResultChip
showIcon={false}
enabled={result.enabled}
label={label}
/>
}
>
<ConditionallyRender
condition={Boolean(strategy.disabled)}
show={
<DisabledStrategyExecution
strategyResult={strategy}
input={input}
/>
}
elseShow={
<StrategyExecution
strategyResult={strategy}
input={input}
percentageFill={theme.palette.background.elevation2}
/>
}
/>
</StrategyItemContainer>
);
};

View File

@ -75,7 +75,7 @@ export const PlaygroundStrategyExecution: FC<StrategyExecutionProps> = ({
<ConstraintListItem key={index}>{param}</ConstraintListItem>
)),
name === 'default' && (
<StyledBoxSummary sx={{ width: '100%' }}>
<StyledBoxSummary sx={{ width: '100%' }} key='default-on'>
The standard strategy is <Badge color='success'>ON</Badge> for
all users.
</StyledBoxSummary>

View File

@ -1,12 +1,4 @@
// deprecated; remove with 'flagOverviewRedesign' flag
import type { VFC } from 'react';
import { useTheme } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ReactComponent as FeatureEnabledIcon } from 'assets/icons/isenabled-true.svg';
import { ReactComponent as FeatureDisabledIcon } from 'assets/icons/isenabled-false.svg';
import WarningOutlined from '@mui/icons-material/WarningOutlined';
import { Badge } from 'component/common/Badge/Badge';
import { useUiFlag } from 'hooks/useUiFlag';
import { PlaygroundResultChip as NewPlaygroundResultChip } from './PlaygroundResultChip';
interface IResultChipProps {
@ -16,74 +8,17 @@ interface IResultChipProps {
showIcon?: boolean;
}
/**
* @deprecated remove with 'flagOverviewRedesign' flag. This pollutes a lot of places in the codebase 😞
*/
export const PlaygroundResultChip: VFC<IResultChipProps> = ({
enabled,
label,
showIcon = true,
}) => {
const theme = useTheme();
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
if (flagOverviewRedesign) {
return (
<NewPlaygroundResultChip
enabled={enabled}
label={label}
showIcon={showIcon}
/>
);
}
const icon = (
<ConditionallyRender
condition={enabled === 'unknown' || enabled === 'unevaluated'}
show={<WarningOutlined color={'warning'} fontSize='inherit' />}
elseShow={
<ConditionallyRender
condition={typeof enabled === 'boolean' && Boolean(enabled)}
show={
<FeatureEnabledIcon
aria-hidden
color={theme.palette.success.main}
strokeWidth='0.25'
/>
}
elseShow={
<FeatureDisabledIcon
aria-hidden
color={theme.palette.error.main}
strokeWidth='0.25'
/>
}
/>
}
/>
);
return (
<ConditionallyRender
condition={enabled === 'unknown' || enabled === 'unevaluated'}
show={
<Badge icon={showIcon ? icon : undefined} color='warning'>
{label}
</Badge>
}
elseShow={
<ConditionallyRender
condition={typeof enabled === 'boolean' && Boolean(enabled)}
show={
<Badge
color='success'
icon={showIcon ? icon : undefined}
>
{label}
</Badge>
}
elseShow={
<Badge color='error' icon={showIcon ? icon : undefined}>
{label}
</Badge>
}
/>
}
/>
);
};
}) => (
<NewPlaygroundResultChip
enabled={enabled}
label={label}
showIcon={showIcon}
/>
);

View File

@ -46,8 +46,8 @@ export const FeatureToggleSwitch: VFC<FeatureToggleSwitchProps> = ({
<PermissionSwitch
tooltip={
isChecked
? `Disable feature in ${environmentName}`
: `Enable feature in ${environmentName}`
? `Disable flag in ${environmentName}`
: `Enable flag in ${environmentName}`
}
checked={value}
environmentId={environmentName}

View File

@ -10,14 +10,12 @@ import {
UPDATE_PROJECT,
} from 'component/providers/AccessProvider/permissions';
import { Alert, styled } from '@mui/material';
import LegacyProjectEnvironment from './ProjectEnvironment/LegacyProjectEnvironment';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import EditDefaultStrategy from './ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy';
import useProjectOverview, {
useProjectOverviewNameOrId,
} from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { useUiFlag } from 'hooks/useUiFlag';
import { ProjectEnvironment } from './ProjectEnvironment/ProjectEnvironment';
const StyledAlert = styled(Alert)(({ theme }) => ({
@ -30,7 +28,6 @@ export const ProjectDefaultStrategySettings = () => {
const { project } = useProjectOverview(projectId);
const navigate = useNavigate();
usePageTitle(`Project default strategy configuration ${projectName}`);
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
if (
!hasAccess(
@ -64,19 +61,12 @@ export const ProjectDefaultStrategySettings = () => {
specific environment. These will be used when you enable a
toggle environment that has no strategies defined
</StyledAlert>
{project?.environments.map((environment) =>
flagOverviewRedesign ? (
<ProjectEnvironment
environment={environment}
key={environment.environment}
/>
) : (
<LegacyProjectEnvironment
environment={environment}
key={environment.environment}
/>
),
)}
{project?.environments.map((environment) => (
<ProjectEnvironment
environment={environment}
key={environment.environment}
/>
))}
</PageContent>
<Routes>
<Route

View File

@ -1,149 +0,0 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
styled,
useTheme,
} from '@mui/material';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { PROJECT_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import type { ProjectEnvironmentType } from '../../../../../../interfaces/environments';
import LegacyProjectEnvironmentDefaultStrategy from './ProjectEnvironmentDefaultStrategy/LegacyProjectEnvironmentDefaultStrategy';
interface IProjectEnvironmentProps {
environment: ProjectEnvironmentType;
}
const StyledProjectEnvironmentOverview = styled('div', {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
borderRadius: theme.shape.borderRadiusLarge,
marginBottom: theme.spacing(2),
backgroundColor: enabled
? theme.palette.background.paper
: theme.palette.envAccordion.disabled,
}));
const StyledAccordion = styled(Accordion)({
boxShadow: 'none',
background: 'none',
});
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
boxShadow: 'none',
padding: theme.spacing(2, 4),
pointerEvents: 'none',
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1, 2),
},
}));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
padding: theme.spacing(3),
background: theme.palette.envAccordion.expanded,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.accordionFooter,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 1),
},
}));
const StyledAccordionBody = styled('div')(({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}));
const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1),
},
}));
const StyledHeader = styled('div', {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
color: enabled ? theme.palette.text.primary : theme.palette.text.secondary,
}));
const StyledHeaderTitle = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
textAlign: 'center',
},
}));
const StyledEnvironmentIcon = styled(EnvironmentIcon)(({ theme }) => ({
[theme.breakpoints.down(560)]: {
marginBottom: '0.5rem',
},
}));
const StyledStringTruncator = styled(StringTruncator)(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightMedium,
[theme.breakpoints.down(560)]: {
textAlign: 'center',
},
}));
const ProjectEnvironment = ({ environment }: IProjectEnvironmentProps) => {
const { environment: name } = environment;
const description = `Default strategy configuration in the ${name} environment`;
const theme = useTheme();
const enabled = false;
return (
<StyledProjectEnvironmentOverview enabled={false}>
<StyledAccordion
expanded={true}
onChange={(e) => e.stopPropagation()}
data-testid={`${PROJECT_ENVIRONMENT_ACCORDION}_${name}`}
className={`environment-accordion ${
enabled ? '' : 'accordion-disabled'
}`}
style={{
outline: `2px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
}}
>
<StyledAccordionSummary>
<StyledHeader data-loading enabled={enabled}>
<StyledHeaderTitle>
<StyledEnvironmentIcon enabled />
<div>
<StyledStringTruncator
text={name}
maxWidth='100'
maxLength={15}
/>
</div>
</StyledHeaderTitle>
</StyledHeader>
</StyledAccordionSummary>
<StyledAccordionDetails>
<StyledAccordionBody>
<StyledAccordionBodyInnerContainer>
<LegacyProjectEnvironmentDefaultStrategy
environment={environment}
description={description}
/>
</StyledAccordionBodyInnerContainer>
</StyledAccordionBody>
</StyledAccordionDetails>
</StyledAccordion>
</StyledProjectEnvironmentOverview>
);
};
export default ProjectEnvironment;

View File

@ -1,94 +0,0 @@
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { Link } from 'react-router-dom';
import Edit from '@mui/icons-material/Edit';
import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import type { ProjectEnvironmentType } from 'interfaces/environments';
import { useMemo } from 'react';
import type { CreateFeatureStrategySchema } from 'openapi';
import {
PROJECT_DEFAULT_STRATEGY_WRITE,
UPDATE_PROJECT,
} from '@server/types/permissions';
import { VariantsSplitPreview } from 'component/common/VariantsSplitPreview/VariantsSplitPreview';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/LegacyStrategyItemContainer';
interface ProjectEnvironmentDefaultStrategyProps {
environment: ProjectEnvironmentType;
description: string;
}
export const formatEditProjectEnvironmentStrategyPath = (
projectId: string,
environmentId: string,
): string => {
const params = new URLSearchParams({ environmentId });
return `/projects/${projectId}/settings/default-strategy/edit?${params}`;
};
const DEFAULT_STRATEGY: CreateFeatureStrategySchema = {
name: 'flexibleRollout',
disabled: false,
constraints: [],
title: '',
parameters: {
rollout: '100',
stickiness: 'default',
groupId: '',
},
};
const ProjectEnvironmentDefaultStrategy = ({
environment,
description,
}: ProjectEnvironmentDefaultStrategyProps) => {
const projectId = useRequiredPathParam('projectId');
const { environment: environmentId, defaultStrategy } = environment;
const editStrategyPath = formatEditProjectEnvironmentStrategyPath(
projectId,
environmentId,
);
const strategy: CreateFeatureStrategySchema = useMemo(() => {
return defaultStrategy ? defaultStrategy : DEFAULT_STRATEGY;
}, [JSON.stringify(defaultStrategy)]);
return (
<>
<StrategyItemContainer
strategy={strategy as any}
description={description}
actions={
<>
<PermissionIconButton
permission={[
PROJECT_DEFAULT_STRATEGY_WRITE,
UPDATE_PROJECT,
]}
environmentId={environmentId}
projectId={projectId}
component={Link}
to={editStrategyPath}
tooltipProps={{
title: `Edit default strategy for "${environmentId}"`,
}}
data-testid={`STRATEGY_EDIT-${strategy?.name}`}
>
<Edit />
</PermissionIconButton>
</>
}
>
<StrategyExecution strategy={strategy} />
{strategy.variants && strategy.variants.length > 0 ? (
<VariantsSplitPreview variants={strategy.variants} />
) : null}
</StrategyItemContainer>
</>
);
};
export default ProjectEnvironmentDefaultStrategy;

View File

@ -1,528 +0,0 @@
import {
Box,
Button,
Card,
Grid,
Popover,
styled,
Accordion,
AccordionSummary,
AccordionDetails,
IconButton,
FormHelperText,
} from '@mui/material';
import Delete from '@mui/icons-material/DeleteOutlined';
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
import { type DragEventHandler, type RefObject, useRef, useState } from 'react';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { MilestoneCardName } from './MilestoneCardName';
import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenu/MilestoneStrategyMenuCards';
import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { ReleasePlanTemplateAddStrategyForm } from '../../MilestoneStrategy/ReleasePlanTemplateAddStrategyForm';
import DragIndicator from '@mui/icons-material/DragIndicator';
import { type OnMoveItem, useDragItem } from 'hooks/useDragItem';
import type { IExtendedMilestonePayload } from 'component/releases/hooks/useTemplateForm';
const StyledMilestoneCard = styled(Card, {
shouldForwardProp: (prop) => prop !== 'hasError',
})<{ hasError: boolean }>(({ theme, hasError }) => ({
marginTop: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxShadow: 'none',
border: `1px solid ${hasError ? theme.palette.error.border : theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.background.default,
}));
const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({
padding: theme.spacing(2, 2),
}));
const StyledGridItem = styled(Grid)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
}));
const StyledAddStrategyButton = styled(Button)(({ theme }) => ({}));
const StyledAccordion = styled(Accordion)(({ theme }) => ({
marginTop: theme.spacing(2),
boxShadow: 'none',
background: 'none',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
backgroundColor: theme.palette.background.default,
'&:before': {
opacity: '0 !important',
},
'&.Mui-expanded': { marginTop: `${theme.spacing(2)} !important` },
}));
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
boxShadow: 'none',
padding: theme.spacing(1.5, 2),
borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1, 2),
},
'&.Mui-focusVisible': {
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(0.5, 2, 0.3, 2),
},
}));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
borderBottomLeftRadius: theme.shape.borderRadiusMedium,
borderBottomRightRadius: theme.shape.borderRadiusMedium,
padding: theme.spacing(0),
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 1),
},
backgroundColor: theme.palette.neutral.light,
}));
const StyledAccordionFooter = styled(Grid)(({ theme }) => ({
padding: theme.spacing(2),
paddingTop: 0,
backgroundColor: theme.palette.background.default,
borderRadius: theme.shape.borderRadiusMedium,
}));
const StyledMilestoneActionGrid = styled(Grid)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
}));
const StyledIconButton = styled(IconButton)(({ theme }) => ({
marginLeft: theme.spacing(1),
color: theme.palette.primary.main,
}));
const StyledDragIcon = styled(IconButton)(({ theme }) => ({
padding: 0,
cursor: 'grab',
transition: 'color 0.2s ease-in-out',
marginRight: theme.spacing(1),
'& > svg': {
color: 'action.active',
},
}));
export interface IMilestoneCardProps {
milestone: IExtendedMilestonePayload;
milestoneChanged: (milestone: IExtendedMilestonePayload) => void;
errors: { [key: string]: string };
clearErrors: () => void;
removable: boolean;
onDeleteMilestone: () => void;
index: number;
onMoveItem: OnMoveItem;
}
export const MilestoneCard = ({
milestone,
milestoneChanged,
errors,
clearErrors,
removable,
onDeleteMilestone,
index,
onMoveItem,
}: IMilestoneCardProps) => {
const [anchor, setAnchor] = useState<Element>();
const [dragItem, setDragItem] = useState<{
id: string;
index: number;
height: number;
} | null>(null);
const [addUpdateStrategyOpen, setAddUpdateStrategyOpen] = useState(false);
const [strategyModeEdit, setStrategyModeEdit] = useState(false);
const [expanded, setExpanded] = useState(Boolean(milestone.startExpanded));
const isPopoverOpen = Boolean(anchor);
const popoverId = isPopoverOpen
? 'MilestoneStrategyMenuPopover'
: undefined;
const dragHandleRef = useRef(null);
const dragItemRef = useDragItem<HTMLTableRowElement>(
index,
onMoveItem,
dragHandleRef,
);
const dragHandle = (
<StyledDragIcon ref={dragHandleRef} disableRipple size='small'>
<DragIndicator titleAccess='Drag to reorder' />
</StyledDragIcon>
);
const onClose = () => {
setAnchor(undefined);
};
const [currentStrategy, setCurrentStrategy] = useState<
Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>
>({
name: 'flexibleRollout',
parameters: { rollout: '50' },
constraints: [],
title: '',
id: 'temp',
});
const milestoneStrategyChanged = (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
const strategies = milestone.strategies || [];
milestoneChanged({
...milestone,
strategies: [
...strategies.map((strat) =>
strat.id === strategy.id ? strategy : strat,
),
],
});
};
const milestoneStrategyAdded = (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
milestoneChanged({
...milestone,
strategies: [
...(milestone.strategies || []),
{
...strategy,
strategyName: strategy.strategyName,
sortOrder: milestone.strategies?.length || 0,
},
],
});
};
const addUpdateStrategy = (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
) => {
const existingStrategy = milestone.strategies?.find(
(strat) => strat.id === strategy.id,
);
if (existingStrategy) {
milestoneStrategyChanged(strategy);
} else {
milestoneStrategyAdded(strategy);
setExpanded(true);
}
setAddUpdateStrategyOpen(false);
setStrategyModeEdit(false);
setCurrentStrategy({
name: 'flexibleRollout',
parameters: { rollout: '50' },
constraints: [],
title: '',
id: 'temp',
});
clearErrors();
};
const openAddUpdateStrategyForm = (
strategy: Omit<IReleasePlanMilestoneStrategy, 'milestoneId'>,
editing: boolean,
) => {
setStrategyModeEdit(editing);
setCurrentStrategy(strategy);
setAddUpdateStrategyOpen(true);
};
const onStrategyDragOver =
(targetId: string) =>
(
ref: RefObject<HTMLDivElement>,
targetIndex: number,
): DragEventHandler<HTMLDivElement> =>
(event) => {
if (dragItem === null || ref.current === null) return;
if (dragItem.index === targetIndex || targetId === dragItem.id)
return;
const { top, bottom } = ref.current.getBoundingClientRect();
const overTargetTop = event.clientY - top < dragItem.height;
const overTargetBottom = bottom - event.clientY < dragItem.height;
const draggingUp = dragItem.index > targetIndex;
// prevent oscillating by only reordering if there is sufficient space
if (
(overTargetTop && draggingUp) ||
(overTargetBottom && !draggingUp)
) {
const oldStrategies = milestone.strategies || [];
const newStrategies = [...oldStrategies];
const movedStrategy = newStrategies.splice(
dragItem.index,
1,
)[0];
newStrategies.splice(targetIndex, 0, movedStrategy);
milestoneChanged({ ...milestone, strategies: newStrategies });
setDragItem({
...dragItem,
index: targetIndex,
});
}
};
const onStrategyDragStartRef =
(
ref: RefObject<HTMLDivElement>,
index: number,
): DragEventHandler<HTMLButtonElement> =>
(event) => {
if (!ref.current || !milestone.strategies) {
return;
}
setDragItem({
id: milestone.strategies[index]?.id,
index,
height: ref.current?.offsetHeight || 0,
});
if (ref?.current) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', ref.current.outerHTML);
event.dataTransfer.setDragImage(ref.current, 20, 20);
}
};
const onStrategyDragEnd = () => {
setDragItem(null);
onReOrderStrategies();
};
const onReOrderStrategies = () => {
if (!milestone.strategies) {
return;
}
const newStrategies = [...milestone.strategies];
newStrategies.forEach((strategy, index) => {
strategy.sortOrder = index;
});
milestoneChanged({ ...milestone, strategies: newStrategies });
};
const milestoneStrategyDeleted = (strategyId: string) => {
const strategies = milestone.strategies || [];
milestoneChanged({
...milestone,
strategies: [
...strategies.filter((strat) => strat.id !== strategyId),
],
});
};
const milestoneNameChanged = (name: string) => {
milestoneChanged({ ...milestone, name });
};
if (!milestone.strategies || milestone.strategies.length === 0) {
return (
<>
<StyledMilestoneCard
hasError={
Boolean(errors?.[milestone.id]) ||
Boolean(errors?.[`${milestone.id}_name`])
}
ref={dragItemRef}
>
<StyledMilestoneCardBody>
<Grid container>
<StyledGridItem item xs={6} md={6}>
{dragHandle}
<MilestoneCardName
milestone={milestone}
errors={errors}
clearErrors={clearErrors}
milestoneNameChanged={milestoneNameChanged}
/>
</StyledGridItem>
<StyledMilestoneActionGrid item xs={6} md={6}>
<Button
variant='outlined'
color='primary'
onClick={(ev) =>
setAnchor(ev.currentTarget)
}
>
Add strategy
</Button>
<StyledIconButton
title='Remove milestone'
onClick={onDeleteMilestone}
disabled={!removable}
>
<Delete />
</StyledIconButton>
<Popover
id={popoverId}
open={isPopoverOpen}
anchorEl={anchor}
onClose={onClose}
onClick={onClose}
PaperProps={{
sx: (theme) => ({
paddingBottom: theme.spacing(1),
}),
}}
>
<MilestoneStrategyMenuCards
openEditAddStrategy={(strategy) => {
openAddUpdateStrategyForm(
strategy,
false,
);
}}
/>
</Popover>
</StyledMilestoneActionGrid>
</Grid>
</StyledMilestoneCardBody>
</StyledMilestoneCard>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
<SidebarModal
label='Add strategy to template milestone'
onClose={() => {
setAddUpdateStrategyOpen(false);
setStrategyModeEdit(false);
}}
open={addUpdateStrategyOpen}
>
<ReleasePlanTemplateAddStrategyForm
strategy={currentStrategy}
onAddUpdateStrategy={addUpdateStrategy}
onCancel={() => {
setAddUpdateStrategyOpen(false);
setStrategyModeEdit(false);
}}
editMode={strategyModeEdit}
/>
</SidebarModal>
</>
);
}
return (
<>
<StyledAccordion
expanded={expanded}
onChange={(e, change) => setExpanded(change)}
>
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
ref={dragItemRef}
>
{dragHandle}
<MilestoneCardName
milestone={milestone}
errors={errors}
clearErrors={clearErrors}
milestoneNameChanged={milestoneNameChanged}
/>
</StyledAccordionSummary>
<StyledAccordionDetails>
{milestone.strategies.map((strg, index) => (
<div key={strg.id}>
<MilestoneStrategyDraggableItem
index={index}
onDragEnd={onStrategyDragEnd}
onDragStartRef={onStrategyDragStartRef}
onDragOver={onStrategyDragOver(strg.id)}
onDeleteClick={() =>
milestoneStrategyDeleted(strg.id)
}
onEditClick={() => {
openAddUpdateStrategyForm(strg, true);
}}
isDragging={dragItem?.id === strg.id}
strategy={strg}
/>
</div>
))}
<StyledAccordionFooter>
<StyledAddStrategyButton
variant='outlined'
color='primary'
onClick={(ev) => setAnchor(ev.currentTarget)}
>
Add strategy
</StyledAddStrategyButton>
<Button
variant='text'
color='primary'
onClick={onDeleteMilestone}
disabled={!removable}
>
<Delete /> Remove milestone
</Button>
<Popover
id={popoverId}
open={isPopoverOpen}
anchorEl={anchor}
onClose={onClose}
onClick={onClose}
PaperProps={{
sx: (theme) => ({
paddingBottom: theme.spacing(1),
}),
}}
>
<MilestoneStrategyMenuCards
openEditAddStrategy={(strategy) => {
openAddUpdateStrategyForm(strategy, false);
}}
/>
</Popover>
</StyledAccordionFooter>
</StyledAccordionDetails>
</StyledAccordion>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
<SidebarModal
label='Add strategy to template milestone'
onClose={() => {
setAddUpdateStrategyOpen(false);
setStrategyModeEdit(false);
}}
open={addUpdateStrategyOpen}
>
<ReleasePlanTemplateAddStrategyForm
strategy={currentStrategy}
onAddUpdateStrategy={addUpdateStrategy}
onCancel={() => {
setAddUpdateStrategyOpen(false);
setStrategyModeEdit(false);
}}
editMode={strategyModeEdit}
/>
</SidebarModal>
</>
);
};

View File

@ -4,8 +4,6 @@ import Add from '@mui/icons-material/Add';
import { v4 as uuidv4 } from 'uuid';
import { useCallback } from 'react';
import type { OnMoveItem } from 'hooks/useDragItem';
import { MilestoneCard as LegacyMilestoneCard } from './MilestoneCard/LegacyMilestoneCard';
import { useUiFlag } from 'hooks/useUiFlag';
import { MilestoneCard } from './MilestoneCard/MilestoneCard';
interface IMilestoneListProps {
@ -30,10 +28,9 @@ export const MilestoneList = ({
clearErrors,
milestoneChanged,
}: IMilestoneListProps) => {
const useNewMilestoneCard = useUiFlag('flagOverviewRedesign');
const onMoveItem: OnMoveItem = useCallback(
async ({ dragIndex, dropIndex, event, draggedElement }) => {
if (useNewMilestoneCard && event.type === 'drop') {
if (event.type === 'drop') {
return; // the user has let go, we should leave the current sort order as it is currently visually displayed
}
@ -83,12 +80,10 @@ export const MilestoneList = ({
);
};
const Card = useNewMilestoneCard ? MilestoneCard : LegacyMilestoneCard;
return (
<>
{milestones.map((milestone, index) => (
<Card
<MilestoneCard
key={milestone.id}
index={index}
onMoveItem={onMoveItem}

View File

@ -1,45 +0,0 @@
import { useGlobalLocalStorage } from './useGlobalLocalStorage';
import { useState } from 'react';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
/**
* @deprecated remove with `flagOverviewRedesign`
*/
export const useHiddenEnvironments = () => {
const { trackEvent } = usePlausibleTracker();
const { value: globalStore, setValue: setGlobalStore } =
useGlobalLocalStorage();
const [hiddenEnvironments, setStoredHiddenEnvironments] = useState<
Set<string>
>(new Set(globalStore.hiddenEnvironments));
const setHiddenEnvironments = (environment: string) => {
setGlobalStore((params) => {
const hiddenEnvironments = new Set(
Array.from(params.hiddenEnvironments || []),
);
if (hiddenEnvironments.has(environment)) {
hiddenEnvironments.delete(environment);
trackEvent('hidden_environment', {
props: {
eventType: `environment unhidden`,
},
});
} else {
hiddenEnvironments.add(environment);
}
setStoredHiddenEnvironments(hiddenEnvironments);
return {
...globalStore,
hiddenEnvironments: [...hiddenEnvironments],
};
});
};
return {
hiddenEnvironments,
setHiddenEnvironments,
};
};

View File

@ -87,7 +87,6 @@ export type UiFlags = {
'enterprise-payg'?: boolean;
productivityReportEmail?: boolean;
showUserDeviceCount?: boolean;
flagOverviewRedesign?: boolean;
consumptionModel?: boolean;
edgeObservability?: boolean;
adminNavUI?: boolean;

View File

@ -1,6 +1,5 @@
import { createTheme } from '@mui/material/styles';
import { colors } from './colors';
import { alpha } from '@mui/material';
import { focusable } from 'themes/themeStyles';
export const baseTheme = {
@ -517,17 +516,6 @@ export const lightTheme = createTheme({
'&:first-of-type, &:last-of-type': {
borderRadius: theme.shape.borderRadiusLarge,
},
// Environment accordion -- remove with `flagOverviewRedesign` flag
'&.environment-accordion.Mui-expanded': {
outline: `2px solid ${alpha(
theme.palette.background.alternative,
0.6,
)}`,
boxShadow: `0px 2px 8px ${alpha(
theme.palette.primary.main,
0.2,
)}`,
},
}),
},
},

View File

@ -51,7 +51,6 @@ export type IFlagKey =
| 'productivityReportEmail'
| 'productivityReportUnsubscribers'
| 'enterprise-payg'
| 'flagOverviewRedesign'
| 'showUserDeviceCount'
| 'memorizeStats'
| 'streaming'
@ -262,10 +261,6 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_SHOW_USER_DEVICE_COUNT,
false,
),
flagOverviewRedesign: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN,
false,
),
streaming: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_STREAMING,
false,

View File

@ -50,7 +50,6 @@ process.nextTick(async () => {
webhookDomainLogging: true,
releasePlans: false,
showUserDeviceCount: true,
flagOverviewRedesign: true,
deltaApi: true,
uniqueSdkTracking: true,
filterExistingFlagNames: true,