1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-18 00:19:49 +01:00

feat: Change request applied diff for update strategy (#8859)

This commit is contained in:
Mateusz Kwasniewski 2024-11-27 12:51:11 +01:00 committed by GitHub
parent 41fb95dd56
commit 570f8d2c34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 411 additions and 175 deletions

View File

@ -0,0 +1,197 @@
import { render } from 'utils/testRenderer';
import { StrategyChange } from './StrategyChange';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const server = testServerSetup();
const feature = 'my_feature';
const projectId = 'default';
const environmentName = 'production';
const snapshotRollout = '70';
const currentRollout = '80';
const changeRequestRollout = '90';
const strategy = {
name: 'flexibleRollout',
constraints: [],
variants: [],
parameters: {
groupId: 'child_1',
stickiness: 'default',
},
segments: [],
id: '8e25e369-6424-4dad-b17f-5d32cceb2fbe',
disabled: false,
};
const setupApi = () => {
testServerRoute(
server,
`/api/admin/projects/${projectId}/features/${feature}`,
{
environments: [
{
name: environmentName,
strategies: [
{
...strategy,
title: 'current_title',
parameters: {
...strategy.parameters,
rollout: currentRollout,
},
},
],
},
],
},
);
};
beforeEach(setupApi);
test('Editing strategy before change request is applied diffs against current strategy', async () => {
render(
<StrategyChange
featureName={feature}
environmentName={environmentName}
projectId={projectId}
changeRequestState='Approved'
change={{
action: 'updateStrategy',
id: 1,
payload: {
...strategy,
title: 'change_request_title',
parameters: {
...strategy.parameters,
rollout: changeRequestRollout,
},
snapshot: {
...strategy,
title: 'snapshot_title',
parameters: {
...strategy.parameters,
rollout: snapshotRollout,
},
},
},
}}
/>,
);
await screen.findByText('Editing strategy:');
await screen.findByText('change_request_title');
await screen.findByText('current_title');
expect(screen.queryByText('snapshot_title')).not.toBeInTheDocument();
const viewDiff = await screen.findByText('View Diff');
await userEvent.hover(viewDiff);
await screen.findByText(`- parameters.rollout: "${currentRollout}"`);
await screen.findByText(`+ parameters.rollout: "${changeRequestRollout}"`);
});
test('Editing strategy after change request is applied diffs against the snapshot', async () => {
render(
<StrategyChange
featureName='my_feature'
environmentName='production'
projectId='default'
changeRequestState='Applied'
change={{
action: 'updateStrategy',
id: 1,
payload: {
...strategy,
title: 'change_request_title',
parameters: {
...strategy.parameters,
rollout: changeRequestRollout,
},
snapshot: {
...strategy,
title: 'snapshot_title',
parameters: {
...strategy.parameters,
rollout: snapshotRollout,
},
},
},
}}
/>,
);
await screen.findByText('Editing strategy:');
await screen.findByText('change_request_title');
await screen.findByText('snapshot_title');
expect(screen.queryByText('current_title')).not.toBeInTheDocument();
const viewDiff = await screen.findByText('View Diff');
await userEvent.hover(viewDiff);
await screen.findByText(`- parameters.rollout: "${snapshotRollout}"`);
await screen.findByText(`+ parameters.rollout: "${changeRequestRollout}"`);
});
test('Deleting strategy before change request is applied diffs against current strategy', async () => {
render(
<StrategyChange
featureName={feature}
environmentName={environmentName}
projectId={projectId}
changeRequestState='Approved'
change={{
action: 'deleteStrategy',
id: 1,
payload: {
id: strategy.id,
name: strategy.name,
},
}}
/>,
);
await screen.findByText('- Deleting strategy:');
await screen.findByText('Gradual rollout');
await screen.findByText('current_title');
const viewDiff = await screen.findByText('View Diff');
await userEvent.hover(viewDiff);
await screen.findByText('- constraints (deleted)');
});
test('Deleting strategy after change request is applied diffs against the snapshot', async () => {
render(
<StrategyChange
featureName={feature}
environmentName={environmentName}
projectId={projectId}
changeRequestState='Applied'
change={{
action: 'deleteStrategy',
id: 1,
payload: {
id: strategy.id,
// name is gone
snapshot: {
...strategy,
title: 'snapshot_title',
parameters: {
...strategy.parameters,
rollout: snapshotRollout,
},
},
},
}}
/>,
);
await screen.findByText('- Deleting strategy:');
await screen.findByText('Gradual rollout');
await screen.findByText('snapshot_title');
expect(screen.queryByText('current_title')).not.toBeInTheDocument();
const viewDiff = await screen.findByText('View Diff');
await userEvent.hover(viewDiff);
await screen.findByText('- constraints (deleted)');
});

View File

@ -1,5 +1,5 @@
import type React from 'react'; import type React from 'react';
import type { VFC, FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { Box, styled, Tooltip, Typography } from '@mui/material'; import { Box, styled, Tooltip, Typography } from '@mui/material';
import BlockIcon from '@mui/icons-material/Block'; import BlockIcon from '@mui/icons-material/Block';
import TrackChangesIcon from '@mui/icons-material/TrackChanges'; import TrackChangesIcon from '@mui/icons-material/TrackChanges';
@ -20,6 +20,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { flexRow } from 'themes/themeStyles'; import { flexRow } from 'themes/themeStyles';
import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable'; import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable';
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning'; import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning';
import type { IFeatureStrategy } from 'interfaces/strategy';
export const ChangeItemWrapper = styled(Box)({ export const ChangeItemWrapper = styled(Box)({
display: 'flex', display: 'flex',
@ -60,10 +61,7 @@ const StyledTypography: FC<{ children?: React.ReactNode }> = styled(Typography)(
}), }),
); );
const hasNameField = (payload: unknown): payload is { name: string } => const DisabledEnabledState: FC<{ show?: boolean; disabled: boolean }> = ({
typeof payload === 'object' && payload !== null && 'name' in payload;
const DisabledEnabledState: VFC<{ show?: boolean; disabled: boolean }> = ({
show = true, show = true,
disabled, disabled,
}) => { }) => {
@ -98,7 +96,7 @@ const DisabledEnabledState: VFC<{ show?: boolean; disabled: boolean }> = ({
); );
}; };
const EditHeader: VFC<{ const EditHeader: FC<{
wasDisabled?: boolean; wasDisabled?: boolean;
willBeDisabled?: boolean; willBeDisabled?: boolean;
}> = ({ wasDisabled = false, willBeDisabled = false }) => { }> = ({ wasDisabled = false, willBeDisabled = false }) => {
@ -119,7 +117,157 @@ const EditHeader: VFC<{
return <Typography>Editing strategy:</Typography>; return <Typography>Editing strategy:</Typography>;
}; };
export const StrategyChange: VFC<{ const hasDiff = (object: unknown, objectToCompare: unknown) =>
JSON.stringify(object) !== JSON.stringify(objectToCompare);
const DeleteStrategy: FC<{
change: IChangeRequestDeleteStrategy;
changeRequestState: ChangeRequestState;
currentStrategy: IFeatureStrategy | undefined;
actions?: ReactNode;
}> = ({ change, changeRequestState, currentStrategy, actions }) => {
const name =
changeRequestState === 'Applied'
? change.payload?.snapshot?.name
: currentStrategy?.name;
const title =
changeRequestState === 'Applied'
? change.payload?.snapshot?.title
: currentStrategy?.title;
const referenceStrategy =
changeRequestState === 'Applied'
? change.payload.snapshot
: currentStrategy;
return (
<>
<ChangeItemCreateEditDeleteWrapper className='delete-strategy-information-wrapper'>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting strategy:
</Typography>
<StrategyTooltipLink name={name || ''} title={title}>
<StrategyDiff
change={change}
currentStrategy={referenceStrategy}
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
{referenceStrategy && (
<StrategyExecution strategy={referenceStrategy} />
)}
<ConditionallyRender
condition={Boolean(referenceStrategy?.variants?.length)}
show={
referenceStrategy?.variants && (
<StyledBox>
<StyledTypography>
Deleting strategy variants:
</StyledTypography>
<EnvironmentVariantsTable
variants={referenceStrategy.variants}
/>
</StyledBox>
)
}
/>
</>
);
};
const UpdateStrategy: FC<{
change: IChangeRequestUpdateStrategy;
changeRequestState: ChangeRequestState;
currentStrategy: IFeatureStrategy | undefined;
actions?: ReactNode;
}> = ({ change, changeRequestState, currentStrategy, actions }) => {
const hasVariantDiff = hasDiff(
currentStrategy?.variants || [],
change.payload.variants || [],
);
const previousTitle =
changeRequestState === 'Applied'
? change.payload.snapshot?.title
: currentStrategy?.title;
const referenceStrategy =
changeRequestState === 'Applied'
? change.payload.snapshot
: currentStrategy;
return (
<>
<ChangeOverwriteWarning
data={{
current: currentStrategy,
change,
changeType: 'strategy',
}}
changeRequestState={changeRequestState}
/>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<EditHeader
wasDisabled={currentStrategy?.disabled}
willBeDisabled={change.payload?.disabled}
/>
<StrategyTooltipLink
name={change.payload.name}
title={change.payload.title}
previousTitle={previousTitle}
>
<StrategyDiff
change={change}
currentStrategy={referenceStrategy}
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ConditionallyRender
condition={
change.payload?.disabled !== currentStrategy?.disabled
}
show={
<Typography
sx={{
marginTop: (theme) => theme.spacing(2),
marginBottom: (theme) => theme.spacing(2),
...flexRow,
gap: (theme) => theme.spacing(1),
}}
>
This strategy will be{' '}
<DisabledEnabledState
disabled={change.payload?.disabled || false}
/>
</Typography>
}
/>
<StrategyExecution strategy={change.payload} />
<ConditionallyRender
condition={Boolean(hasVariantDiff)}
show={
<StyledBox>
<StyledTypography>
Updating feature variants to:
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants || []}
/>
</StyledBox>
}
/>
</>
);
};
export const StrategyChange: FC<{
actions?: ReactNode; actions?: ReactNode;
change: change:
| IChangeRequestAddStrategy | IChangeRequestAddStrategy
@ -144,16 +292,6 @@ export const StrategyChange: VFC<{
environmentName, environmentName,
); );
const hasDiff = (object: unknown, objectToCompare: unknown) =>
JSON.stringify(object) !== JSON.stringify(objectToCompare);
const isStrategyAction =
change.action === 'addStrategy' || change.action === 'updateStrategy';
const hasVariantDiff =
isStrategyAction &&
hasDiff(currentStrategy?.variants || [], change.payload.variants || []);
return ( return (
<> <>
{change.action === 'addStrategy' && ( {change.action === 'addStrategy' && (
@ -169,7 +307,10 @@ export const StrategyChange: VFC<{
> >
+ Adding strategy: + Adding strategy:
</Typography> </Typography>
<StrategyTooltipLink change={change}> <StrategyTooltipLink
name={change.payload.name}
title={change.payload.title}
>
<StrategyDiff <StrategyDiff
change={change} change={change}
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
@ -185,139 +326,35 @@ export const StrategyChange: VFC<{
<div>{actions}</div> <div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper> </ChangeItemCreateEditDeleteWrapper>
<StrategyExecution strategy={change.payload} /> <StrategyExecution strategy={change.payload} />
<ConditionallyRender {change.payload.variants &&
condition={hasVariantDiff} change.payload.variants.length > 0 && (
show={
change.payload.variants && (
<StyledBox>
<StyledTypography>
Updating feature variants to:
</StyledTypography>
<EnvironmentVariantsTable
variants={change.payload.variants}
/>
</StyledBox>
)
}
/>
</>
)}
{change.action === 'deleteStrategy' && (
<>
<ChangeItemCreateEditDeleteWrapper className='delete-strategy-information-wrapper'>
<ChangeItemInfo>
<Typography
sx={(theme) => ({
color: theme.palette.error.main,
})}
>
- Deleting strategy:
</Typography>
{hasNameField(change.payload) && (
<StrategyTooltipLink change={change}>
<StrategyDiff
change={change}
currentStrategy={currentStrategy}
/>
</StrategyTooltipLink>
)}
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ConditionallyRender
condition={Boolean(currentStrategy)}
show={
<Typography>
{
<StrategyExecution
strategy={currentStrategy!}
/>
}
</Typography>
}
/>
<ConditionallyRender
condition={Boolean(currentStrategy?.variants?.length)}
show={
currentStrategy?.variants && (
<StyledBox>
<StyledTypography>
Deleting strategy variants:
</StyledTypography>
<EnvironmentVariantsTable
variants={currentStrategy.variants}
/>
</StyledBox>
)
}
/>
</>
)}
{change.action === 'updateStrategy' && (
<>
<ChangeOverwriteWarning
data={{
current: currentStrategy,
change,
changeType: 'strategy',
}}
changeRequestState={changeRequestState}
/>
<ChangeItemCreateEditDeleteWrapper>
<ChangeItemInfo>
<EditHeader
wasDisabled={currentStrategy?.disabled}
willBeDisabled={change.payload?.disabled}
/>
<StrategyTooltipLink
change={change}
previousTitle={currentStrategy?.title}
>
<StrategyDiff
change={change}
currentStrategy={currentStrategy}
/>
</StrategyTooltipLink>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ConditionallyRender
condition={
change.payload?.disabled !==
currentStrategy?.disabled
}
show={
<Typography
sx={{
marginTop: (theme) => theme.spacing(2),
marginBottom: (theme) => theme.spacing(2),
...flexRow,
gap: (theme) => theme.spacing(1),
}}
>
This strategy will be{' '}
<DisabledEnabledState
disabled={change.payload?.disabled || false}
/>
</Typography>
}
/>
<StrategyExecution strategy={change.payload} />
<ConditionallyRender
condition={Boolean(hasVariantDiff)}
show={
<StyledBox> <StyledBox>
<StyledTypography> <StyledTypography>
Updating feature variants to: Updating feature variants to:
</StyledTypography> </StyledTypography>
<EnvironmentVariantsTable <EnvironmentVariantsTable
variants={change.payload.variants || []} variants={change.payload.variants}
/> />
</StyledBox> </StyledBox>
} )}
/>
</> </>
)} )}
{change.action === 'deleteStrategy' && (
<DeleteStrategy
change={change}
changeRequestState={changeRequestState}
currentStrategy={currentStrategy}
actions={actions}
/>
)}
{change.action === 'updateStrategy' && (
<UpdateStrategy
change={change}
changeRequestState={changeRequestState}
currentStrategy={currentStrategy}
actions={actions}
/>
)}
</> </>
); );
}; };

View File

@ -51,10 +51,8 @@ export const StrategyDiff: FC<{
}; };
interface IStrategyTooltipLinkProps { interface IStrategyTooltipLinkProps {
change: name: string;
| IChangeRequestAddStrategy title?: string;
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
previousTitle?: string; previousTitle?: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -80,29 +78,32 @@ const Truncated = styled('div')(() => ({
})); }));
export const StrategyTooltipLink: FC<IStrategyTooltipLinkProps> = ({ export const StrategyTooltipLink: FC<IStrategyTooltipLinkProps> = ({
change, name,
title,
previousTitle, previousTitle,
children, children,
}) => ( }) => {
<StyledContainer> return (
<GetFeatureStrategyIcon strategyName={change.payload.name} /> <StyledContainer>
<Truncated> <GetFeatureStrategyIcon strategyName={name} />
<Typography component='span'> <Truncated>
{formatStrategyName(change.payload.name)} <Typography component='span'>
</Typography> {formatStrategyName(name)}
<TooltipLink </Typography>
tooltip={children} <TooltipLink
tooltipProps={{ tooltip={children}
maxWidth: 500, tooltipProps={{
maxHeight: 600, maxWidth: 500,
}} maxHeight: 600,
> }}
<ViewDiff>View Diff</ViewDiff> >
</TooltipLink> <ViewDiff>View Diff</ViewDiff>
<NameWithChangeInfo </TooltipLink>
newName={change.payload.title} <NameWithChangeInfo
previousName={previousTitle} newName={title}
/> previousName={previousTitle}
</Truncated> />
</StyledContainer> </Truncated>
); </StyledContainer>
);
};

View File

@ -241,14 +241,15 @@ export type ChangeRequestAddStrategy = Pick<
export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & {
id: string; id: string;
snapshot?: Omit<IFeatureStrategy, 'title'> & { title?: string | null }; snapshot?: IFeatureStrategy;
}; };
type ChangeRequestDeleteStrategy = { type ChangeRequestDeleteStrategy = {
id: string; id: string;
name: string; name?: string;
title?: string; title?: string;
disabled?: boolean; disabled?: boolean;
snapshot?: IFeatureStrategy;
}; };
export type ChangeRequestAction = export type ChangeRequestAction =

View File

@ -1,4 +1,4 @@
import { Fragment, useMemo, type VFC } from 'react'; import { type FC, Fragment, useMemo } from 'react';
import { Alert, Box, Chip, Link, styled } from '@mui/material'; import { Alert, Box, Chip, Link, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle'; import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
@ -56,7 +56,7 @@ const CustomStrategyDeprecationWarning = () => (
</Alert> </Alert>
); );
const NoItems: VFC = () => ( const NoItems: FC = () => (
<Box sx={{ px: 3, color: 'text.disabled' }}> <Box sx={{ px: 3, color: 'text.disabled' }}>
This strategy does not have constraints or parameters. This strategy does not have constraints or parameters.
</Box> </Box>
@ -73,7 +73,7 @@ const StyledValueSeparator = styled('span')(({ theme }) => ({
color: theme.palette.neutral.main, color: theme.palette.neutral.main,
})); }));
export const StrategyExecution: VFC<IStrategyExecutionProps> = ({ export const StrategyExecution: FC<IStrategyExecutionProps> = ({
strategy, strategy,
}) => { }) => {
const { parameters, constraints = [] } = strategy; const { parameters, constraints = [] } = strategy;