1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat: show segment conflicts in crs (#6138)

This PR updates the diff calculation to work with both strategy changes
and segment changes. It also adds the corresponding segment change
conflict overview to segment updates.

<img width="1225" alt="image"
src="https://github.com/Unleash/unleash/assets/17786332/688a57a5-5cd7-4b0a-bd1e-df63189594d8">
This commit is contained in:
Thomas Heartman 2024-02-09 16:25:01 +09:00 committed by GitHub
parent ba2cde7c50
commit b77f3129f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 309 additions and 134 deletions

View File

@ -7,6 +7,7 @@ import {
import { useSegment } from 'hooks/api/getters/useSegment/useSegment'; import { useSegment } from 'hooks/api/getters/useSegment/useSegment';
import { SegmentDiff, SegmentTooltipLink } from '../../SegmentTooltipLink'; import { SegmentDiff, SegmentTooltipLink } from '../../SegmentTooltipLink';
import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList'; import { ConstraintAccordionList } from 'component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
import { SegmentChangesToOverwrite } from './StrategyChangeOverwriteWarning';
const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({ const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({
display: 'grid', display: 'grid',
@ -77,6 +78,10 @@ export const SegmentChangeDetails: VFC<{
)} )}
{change.action === 'updateSegment' && ( {change.action === 'updateSegment' && (
<> <>
<SegmentChangesToOverwrite
currentSegment={currentSegment}
change={change}
/>
<ChangeItemCreateEditWrapper> <ChangeItemCreateEditWrapper>
<ChangeItemInfo> <ChangeItemInfo>
<Typography>Editing segment:</Typography> <Typography>Editing segment:</Typography>

View File

@ -17,7 +17,7 @@ import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
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 { ChangesToOverwrite } from './StrategyChangeOverwriteWarning'; import { StrategyChangesToOverwrite } from './StrategyChangeOverwriteWarning';
export const ChangeItemWrapper = styled(Box)({ export const ChangeItemWrapper = styled(Box)({
display: 'flex', display: 'flex',
@ -210,7 +210,7 @@ export const StrategyChange: VFC<{
)} )}
{change.action === 'updateStrategy' && ( {change.action === 'updateStrategy' && (
<> <>
<ChangesToOverwrite <StrategyChangesToOverwrite
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
change={change} change={change}
/> />

View File

@ -1,12 +1,20 @@
import { Box, styled } from '@mui/material'; import { Box, styled } from '@mui/material';
import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types';
import { useChangeRequestPlausibleContext } from 'component/changeRequest/ChangeRequestContext'; import { useChangeRequestPlausibleContext } from 'component/changeRequest/ChangeRequestContext';
import {
IChangeRequestUpdateSegment,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { ISegment } from 'interfaces/segment';
import { IFeatureStrategy } from 'interfaces/strategy'; import { IFeatureStrategy } from 'interfaces/strategy';
import { getChangesThatWouldBeOverwritten } from './strategy-change-diff-calculation'; import {
ChangesThatWouldBeOverwritten,
getStrategyChangesThatWouldBeOverwritten,
getSegmentChangesThatWouldBeOverwritten,
} from './strategy-change-diff-calculation';
import { useEffect } from 'react'; import { useEffect } from 'react';
const ChangesToOverwriteWarning = styled(Box)(({ theme }) => ({ const ChangesToOverwriteContainer = styled(Box)(({ theme }) => ({
color: theme.palette.warning.dark, color: theme.palette.warning.dark,
backgroundColor: theme.palette.warning.light, backgroundColor: theme.palette.warning.light,
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
@ -70,13 +78,113 @@ const OverwriteTable = styled('table')(({ theme }) => ({
}, },
})); }));
export const ChangesToOverwrite: React.FC<{ const DetailsTable: React.FC<{
changesThatWouldBeOverwritten: ChangesThatWouldBeOverwritten;
}> = ({ changesThatWouldBeOverwritten }) => {
return (
<OverwriteTable>
<thead>
<tr>
<th>Property</th>
<th>Current value</th>
<th>Value after change</th>
</tr>
</thead>
<tbody>
{changesThatWouldBeOverwritten.map(
({ property, oldValue, newValue }) => (
<tr key={property}>
<td data-column='Property'>{property}</td>
<td data-column='Current value'>
<pre>
<del>
{JSON.stringify(oldValue, null, 2)
.split('\n')
.map((line, index) => (
<code
key={`${property}${line}${index}`}
>
{`${line}\n`}
</code>
))}
</del>
</pre>
</td>
<td data-column='Value after change'>
<pre>
<ins>
{JSON.stringify(newValue, null, 2)
.split('\n')
.map((line, index) => (
<code
key={`${property}${line}${index}`}
>
{`${line}\n`}
</code>
))}
</ins>
</pre>
</td>
</tr>
),
)}
</tbody>
</OverwriteTable>
);
};
const OverwriteWarning: React.FC<{
changeType: 'segment' | 'strategy';
changesThatWouldBeOverwritten: ChangesThatWouldBeOverwritten;
}> = ({ changeType, changesThatWouldBeOverwritten }) => {
return (
<ChangesToOverwriteContainer>
<p>
<strong>Heads up!</strong> The ${changeType} has been updated
since you made your changes. Applying this change now would
overwrite the configuration that is currently live.
</p>
<details>
<summary>Changes that would be overwritten</summary>
<DetailsTable
changesThatWouldBeOverwritten={
changesThatWouldBeOverwritten
}
/>
</details>
</ChangesToOverwriteContainer>
);
};
export const SegmentChangesToOverwrite: React.FC<{
currentSegment?: ISegment;
change: IChangeRequestUpdateSegment;
}> = ({ change, currentSegment }) => {
const checkForChanges = useUiFlag('changeRequestConflictHandling');
const changesThatWouldBeOverwritten = checkForChanges
? getSegmentChangesThatWouldBeOverwritten(currentSegment, change)
: null;
if (!changesThatWouldBeOverwritten) {
return null;
}
return (
<OverwriteWarning
changeType='segment'
changesThatWouldBeOverwritten={changesThatWouldBeOverwritten}
/>
);
};
export const StrategyChangesToOverwrite: React.FC<{
currentStrategy?: IFeatureStrategy; currentStrategy?: IFeatureStrategy;
change: IChangeRequestUpdateStrategy; change: IChangeRequestUpdateStrategy;
}> = ({ change, currentStrategy }) => { }> = ({ change, currentStrategy }) => {
const checkForChanges = useUiFlag('changeRequestConflictHandling'); const checkForChanges = useUiFlag('changeRequestConflictHandling');
const changesThatWouldBeOverwritten = checkForChanges const changesThatWouldBeOverwritten = checkForChanges
? getChangesThatWouldBeOverwritten(currentStrategy, change) ? getStrategyChangesThatWouldBeOverwritten(currentStrategy, change)
: null; : null;
const { registerWillOverwriteStrategyChanges } = const { registerWillOverwriteStrategyChanges } =
useChangeRequestPlausibleContext(); useChangeRequestPlausibleContext();
@ -92,73 +200,9 @@ export const ChangesToOverwrite: React.FC<{
} }
return ( return (
<ChangesToOverwriteWarning> <OverwriteWarning
<p> changeType='strategy'
<strong>Heads up!</strong> The strategy has been updated since changesThatWouldBeOverwritten={changesThatWouldBeOverwritten}
you made your changes. Applying this change now would overwrite />
the configuration that is currently live.
</p>
<details>
<summary>Changes that would be overwritten</summary>
<OverwriteTable>
<thead>
<tr>
<th>Property</th>
<th>Current value</th>
<th>Value after change</th>
</tr>
</thead>
<tbody>
{changesThatWouldBeOverwritten.map(
({ property, oldValue, newValue }) => (
<tr key={property}>
<td data-column='Property'>{property}</td>
<td data-column='Current value'>
<pre>
<del>
{JSON.stringify(
oldValue,
null,
2,
)
.split('\n')
.map((line, index) => (
<code
key={`${property}${line}${index}`}
>
{`${line}\n`}
</code>
))}
</del>
</pre>
</td>
<td data-column='Value after change'>
<pre>
<ins>
{JSON.stringify(
newValue,
null,
2,
)
.split('\n')
.map((line, index) => (
<code
key={`${property}${line}${index}`}
>
{`${line}\n`}
</code>
))}
</ins>
</pre>
</td>
</tr>
),
)}
</tbody>
</OverwriteTable>
</details>
</ChangesToOverwriteWarning>
); );
}; };

View File

@ -1,10 +1,14 @@
import { import {
ChangeRequestEditStrategy, ChangeRequestEditStrategy,
IChangeRequestUpdateSegment,
IChangeRequestUpdateStrategy, IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types'; } from 'component/changeRequest/changeRequest.types';
import { IFeatureStrategy } from 'interfaces/strategy'; import { IFeatureStrategy } from 'interfaces/strategy';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
import { getChangesThatWouldBeOverwritten } from './strategy-change-diff-calculation'; import {
getSegmentChangesThatWouldBeOverwritten,
getStrategyChangesThatWouldBeOverwritten,
} from './strategy-change-diff-calculation';
describe('Strategy change conflict detection', () => { describe('Strategy change conflict detection', () => {
const existingStrategy: IFeatureStrategy = { const existingStrategy: IFeatureStrategy = {
@ -67,7 +71,7 @@ describe('Strategy change conflict detection', () => {
}; };
test('It compares strategies regardless of order of keys in the objects', () => { test('It compares strategies regardless of order of keys in the objects', () => {
const result = getChangesThatWouldBeOverwritten( const result = getStrategyChangesThatWouldBeOverwritten(
existingStrategy, existingStrategy,
change, change,
); );
@ -76,7 +80,7 @@ describe('Strategy change conflict detection', () => {
}); });
test('It treats `undefined` or missing segments in old config as equal to `[]` in change', () => { test('It treats `undefined` or missing segments in old config as equal to `[]` in change', () => {
const resultUndefined = getChangesThatWouldBeOverwritten( const resultUndefined = getStrategyChangesThatWouldBeOverwritten(
{ {
...existingStrategy, ...existingStrategy,
segments: undefined, segments: undefined,
@ -87,7 +91,7 @@ describe('Strategy change conflict detection', () => {
expect(resultUndefined).toBeNull(); expect(resultUndefined).toBeNull();
const { segments, ...withoutSegments } = existingStrategy; const { segments, ...withoutSegments } = existingStrategy;
const resultMissing = getChangesThatWouldBeOverwritten( const resultMissing = getStrategyChangesThatWouldBeOverwritten(
withoutSegments, withoutSegments,
change, change,
); );
@ -132,7 +136,10 @@ describe('Strategy change conflict detection', () => {
].flatMap((existing) => ].flatMap((existing) =>
[undefinedVariantsInSnapshot, missingVariantsInSnapshot].map( [undefinedVariantsInSnapshot, missingVariantsInSnapshot].map(
(changeValue) => (changeValue) =>
getChangesThatWouldBeOverwritten(existing, changeValue), getStrategyChangesThatWouldBeOverwritten(
existing,
changeValue,
),
), ),
); );
@ -175,7 +182,10 @@ describe('Strategy change conflict detection', () => {
segments: [3], segments: [3],
}; };
const result = getChangesThatWouldBeOverwritten(withChanges, change); const result = getStrategyChangesThatWouldBeOverwritten(
withChanges,
change,
);
const { id, name, ...changedProperties } = withChanges; const { id, name, ...changedProperties } = withChanges;
@ -228,7 +238,7 @@ describe('Strategy change conflict detection', () => {
}; };
expect( expect(
getChangesThatWouldBeOverwritten( getStrategyChangesThatWouldBeOverwritten(
existingStrategyMod, existingStrategyMod,
constraintChange, constraintChange,
), ),
@ -255,7 +265,7 @@ describe('Strategy change conflict detection', () => {
], ],
}; };
const result = getChangesThatWouldBeOverwritten( const result = getStrategyChangesThatWouldBeOverwritten(
existingStrategyWithVariants, existingStrategyWithVariants,
{ {
...change, ...change,
@ -276,16 +286,22 @@ describe('Strategy change conflict detection', () => {
}); });
test('it returns null if the existing strategy is undefined', () => { test('it returns null if the existing strategy is undefined', () => {
const result = getChangesThatWouldBeOverwritten(undefined, change); const result = getStrategyChangesThatWouldBeOverwritten(
undefined,
change,
);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test('it returns null if the snapshot is missing', () => { test('it returns null if the snapshot is missing', () => {
const { snapshot, ...payload } = change.payload; const { snapshot, ...payload } = change.payload;
const result = getChangesThatWouldBeOverwritten(existingStrategy, { const result = getStrategyChangesThatWouldBeOverwritten(
...change, existingStrategy,
payload, {
}); ...change,
payload,
},
);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -338,7 +354,7 @@ describe('Strategy change conflict detection', () => {
emptyTitleInSnapshot, emptyTitleInSnapshot,
missingTitleInSnapshot, missingTitleInSnapshot,
].map((changeValue) => ].map((changeValue) =>
getChangesThatWouldBeOverwritten(existing, changeValue), getStrategyChangesThatWouldBeOverwritten(existing, changeValue),
), ),
); );
@ -358,7 +374,7 @@ describe('Strategy change conflict detection', () => {
title: 'other-new-title', title: 'other-new-title',
}, },
}; };
const result = getChangesThatWouldBeOverwritten( const result = getStrategyChangesThatWouldBeOverwritten(
liveVersion, liveVersion,
changedVersion, changedVersion,
); );
@ -385,7 +401,7 @@ describe('Strategy change conflict detection', () => {
title: liveVersion.title, title: liveVersion.title,
}, },
}; };
const result = getChangesThatWouldBeOverwritten( const result = getStrategyChangesThatWouldBeOverwritten(
liveVersion, liveVersion,
changedVersion, changedVersion,
); );
@ -401,7 +417,7 @@ describe('Strategy change conflict detection', () => {
title: 'new-title', title: 'new-title',
}, },
}; };
const result = getChangesThatWouldBeOverwritten( const result = getStrategyChangesThatWouldBeOverwritten(
existingStrategy, existingStrategy,
changedVersion, changedVersion,
); );
@ -409,3 +425,82 @@ describe('Strategy change conflict detection', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe('Segment change conflict detection', () => {
const snapshot = {
id: 12,
name: 'Original name',
project: 'change-request-conflict-handling',
createdAt: '2024-02-06T09:11:23.782Z',
createdBy: 'admin',
constraints: [],
description: '',
};
const change: IChangeRequestUpdateSegment = {
id: 39,
action: 'updateSegment' as const,
name: 'what?a',
payload: {
id: 12,
name: 'Original name',
project: 'change-request-conflict-handling',
constraints: [],
snapshot,
},
};
test('it registers any change in constraints as everything will be overwritten', () => {
const segmentWithConstraints = {
...snapshot,
constraints: [
{
values: ['blah'],
inverted: false,
operator: 'IN' as const,
contextName: 'appName',
caseInsensitive: false,
},
],
};
const changeWithConstraints = {
...change,
payload: {
...change.payload,
constraints: [
...segmentWithConstraints.constraints,
{
values: ['bluh'],
inverted: false,
operator: 'IN' as const,
contextName: 'appName',
caseInsensitive: false,
},
],
},
};
const result = getSegmentChangesThatWouldBeOverwritten(
segmentWithConstraints,
changeWithConstraints,
);
expect(result).toStrictEqual([
{
property: 'constraints',
oldValue: segmentWithConstraints.constraints,
newValue: changeWithConstraints.payload.constraints,
},
]);
});
test('It treats missing description in change as equal to an empty description in snapshot', () => {
const result = getSegmentChangesThatWouldBeOverwritten(
snapshot,
change,
);
expect(result).toBeNull();
});
});

View File

@ -1,7 +1,8 @@
import { import {
ChangeRequestEditStrategy, IChangeRequestUpdateSegment,
IChangeRequestUpdateStrategy, IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types'; } from 'component/changeRequest/changeRequest.types';
import { ISegment } from 'interfaces/segment';
import { IFeatureStrategy } from 'interfaces/strategy'; import { IFeatureStrategy } from 'interfaces/strategy';
import isEqual from 'lodash.isequal'; import isEqual from 'lodash.isequal';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
@ -33,65 +34,69 @@ const hasChanged = (
return hasJsonDiff()(snapshotValue, currentValue, changeValue); return hasJsonDiff()(snapshotValue, currentValue, changeValue);
}; };
type DataToOverwrite<Prop extends keyof ChangeRequestEditStrategy> = { type DataToOverwrite = {
property: Prop; property: string;
oldValue: ChangeRequestEditStrategy[Prop]; oldValue: unknown;
newValue: ChangeRequestEditStrategy[Prop]; newValue: unknown;
}; };
type ChangesThatWouldBeOverwritten = DataToOverwrite< export type ChangesThatWouldBeOverwritten = DataToOverwrite[];
keyof ChangeRequestEditStrategy
>[];
const removeEmptyEntries = ( function isNotUndefined<T>(value: T | undefined): value is T {
change: unknown, return value !== undefined;
): change is DataToOverwrite<keyof ChangeRequestEditStrategy> => }
Boolean(change);
const getChangedProperty = ( const getChangedPropertyWithFallbacks =
key: keyof ChangeRequestEditStrategy, (fallbacks: { [key: string]: unknown }) =>
currentValue: unknown, (
snapshotValue: unknown, key: string,
changeValue: unknown, currentValue: unknown,
) => { snapshotValue: unknown,
const fallbacks = { segments: [], variants: [], title: '' }; changeValue: unknown,
const fallback = fallbacks[key as keyof typeof fallbacks] ?? undefined; ) => {
const diffCheck = key in fallbacks ? hasJsonDiff(fallback) : hasChanged; const fallback = fallbacks[key as keyof typeof fallbacks] ?? undefined;
const diffCheck = key in fallbacks ? hasJsonDiff(fallback) : hasChanged;
const changeInfo = { const changeInfo = {
property: key as keyof ChangeRequestEditStrategy, property: key,
oldValue: currentValue, oldValue: currentValue,
newValue: changeValue, newValue: changeValue,
};
return diffCheck(snapshotValue, currentValue, changeValue)
? changeInfo
: undefined;
}; };
return diffCheck(snapshotValue, currentValue, changeValue) type Change<T> = {
? changeInfo payload: Partial<T> & {
: undefined; snapshot?: { [Key in keyof T]: unknown };
};
}; };
export const getChangesThatWouldBeOverwritten = ( function getChangesThatWouldBeOverwritten<T>(
currentStrategyConfig: IFeatureStrategy | undefined, currentConfig: T | undefined,
change: IChangeRequestUpdateStrategy, change: Change<T>,
): ChangesThatWouldBeOverwritten | null => { fallbacks: Partial<T>,
): ChangesThatWouldBeOverwritten | null {
const { snapshot } = change.payload; const { snapshot } = change.payload;
if (!snapshot || !currentStrategyConfig) return null; if (!snapshot || !currentConfig) return null;
const changes: ChangesThatWouldBeOverwritten = Object.entries( const getChangedProperty = getChangedPropertyWithFallbacks(fallbacks);
omit(currentStrategyConfig, 'strategyName'),
) const changes: ChangesThatWouldBeOverwritten = Object.entries(currentConfig)
.map(([key, currentValue]: [string, unknown]) => { .map(([key, currentValue]: [string, unknown]) => {
const snapshotValue = snapshot[key as keyof IFeatureStrategy]; const snapshotValue = snapshot[key as keyof T];
const changeValue = const changeValue = change.payload[key as keyof T];
change.payload[key as keyof ChangeRequestEditStrategy];
return getChangedProperty( return getChangedProperty(
key as keyof ChangeRequestEditStrategy, key,
currentValue, currentValue,
snapshotValue, snapshotValue,
changeValue, changeValue,
); );
}) })
.filter(removeEmptyEntries); .filter(isNotUndefined);
if (changes.length) { if (changes.length) {
changes.sort((a, b) => a.property.localeCompare(b.property)); changes.sort((a, b) => a.property.localeCompare(b.property));
@ -99,4 +104,28 @@ export const getChangesThatWouldBeOverwritten = (
} }
return null; return null;
}; }
export function getSegmentChangesThatWouldBeOverwritten(
currentSegmentConfig: ISegment | undefined,
change: IChangeRequestUpdateSegment,
): ChangesThatWouldBeOverwritten | null {
const fallbacks = { description: '' };
return getChangesThatWouldBeOverwritten(
omit(currentSegmentConfig, 'createdAt', 'createdBy'),
change,
fallbacks,
);
}
export function getStrategyChangesThatWouldBeOverwritten(
currentStrategyConfig: IFeatureStrategy | undefined,
change: IChangeRequestUpdateStrategy,
): ChangesThatWouldBeOverwritten | null {
const fallbacks = { segments: [], variants: [], title: '' };
return getChangesThatWouldBeOverwritten(
omit(currentStrategyConfig, 'strategyName'),
change,
fallbacks,
);
}

View File

@ -1,4 +1,5 @@
import { IFeatureVariant } from 'interfaces/featureToggle'; import { IFeatureVariant } from 'interfaces/featureToggle';
import { ISegment } from 'interfaces/segment';
import { IFeatureStrategy } from '../../interfaces/strategy'; import { IFeatureStrategy } from '../../interfaces/strategy';
import { IUser } from '../../interfaces/user'; import { IUser } from '../../interfaces/user';
import { SetStrategySortOrderSchema } from '../../openapi'; import { SetStrategySortOrderSchema } from '../../openapi';
@ -183,6 +184,7 @@ export interface IChangeRequestUpdateSegment {
description?: string; description?: string;
project?: string; project?: string;
constraints: IFeatureStrategy['constraints']; constraints: IFeatureStrategy['constraints'];
snapshot?: ISegment;
}; };
} }