1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-26 01:17:00 +02:00

Feat: scheduled change request badges (#5300)

Adds a new badge to strategies that have changes in an a scheduled
change request

Closes #
[1-1620](https://linear.app/unleash/issue/1-1620/create-a-new-badge-for-flag-that-is-part-of-scheduled-change)
<img width="1671" alt="Screenshot 2023-11-09 at 11 49 53"
src="https://github.com/Unleash/unleash/assets/104830839/596abbc0-f9ab-4642-9ed2-79ef50fb6c05">

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
andreas-unleash 2023-11-09 13:48:29 +02:00 committed by GitHub
parent ece5a634bf
commit 100c22b42a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 548 additions and 32 deletions

View File

@ -0,0 +1,40 @@
import { Box, useMediaQuery, useTheme } from '@mui/material';
import { StyledLink } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { Badge } from 'component/common/Badge/Badge';
export interface IChangesScheduledBadgeProps {
scheduledChangeRequestIds: number[];
}
export const ChangesScheduledBadge = ({
scheduledChangeRequestIds,
}: IChangesScheduledBadgeProps) => {
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const project = useRequiredPathParam('projectId');
if (isSmallScreen) {
return null;
}
return (
<Box sx={{ mr: 1.5 }}>
<TooltipLink
tooltip={
<>
{scheduledChangeRequestIds?.map((id, index) => (
<StyledLink
key={`${project}-${index}`}
to={`/projects/${project}/change-requests/${id}`}
>
Change request #{id}
</StyledLink>
))}
</>
}
>
<Badge color='warning'>Changes Scheduled</Badge>
</TooltipLink>
</Box>
);
};

View File

@ -0,0 +1,398 @@
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { render } from 'utils/testRenderer';
import { StrategyDraggableItem } from './StrategyDraggableItem';
import { vi } from 'vitest';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { screen } from '@testing-library/dom';
import { Route, Routes } from 'react-router-dom';
import {
IChangeRequest,
ChangeRequestAction,
} from 'component/changeRequest/changeRequest.types';
const server = testServerSetup();
const strategy = {
name: 'flexibleRollout',
constraints: [],
variants: [],
parameters: {
groupId: 'CR-toggle',
rollout: '100',
stickiness: 'default',
},
sortOrder: 0,
id: 'b6363cc8-ad8e-478a-b464-484bbd3b31f6',
title: '',
disabled: false,
};
const draftRequest = (
action: Omit<ChangeRequestAction, 'updateSegment'> = 'updateStrategy',
createdBy = 1,
): IChangeRequest => {
return {
id: 71,
title: 'Change request #71',
environment: 'production',
minApprovals: 1,
project: 'dafault',
createdBy: {
id: createdBy,
username: 'admin',
imageUrl:
'https://gravatar.com/avatar/8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918?s=42&d=retro&r=g',
},
createdAt: new Date('2023-11-08T10:28:47.183Z'),
features: [
{
name: 'feature1',
changes: [
{
id: 84,
action: action as any,
payload: {
id: 'b6363cc8-ad8e-478a-b464-484bbd3b31f6',
name: 'flexibleRollout',
title: '',
disabled: false,
segments: [],
variants: [],
parameters: {
groupId: 'CR-toggle',
rollout: '15',
stickiness: 'default',
},
constraints: [],
},
createdAt: new Date('2023-11-08T10:28:47.183Z'),
createdBy: {
id: 1,
username: 'admin',
imageUrl:
'https://gravatar.com/avatar/8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918?s=42&d=retro&r=g',
},
},
],
},
],
segments: [],
approvals: [],
rejections: [],
comments: [],
state: 'In review',
};
};
const scheduledRequest = (
action: Omit<ChangeRequestAction, 'updateSegment'>,
) => ({
...draftRequest(action),
state: 'Scheduled',
schedule: {
scheduledAt: new Date().toISOString(),
status: 'pending',
},
});
const uiConfig = () => {
testServerRoute(server, '/api/admin/ui-config', {
versionInfo: {
current: { oss: 'version', enterprise: 'version' },
},
flags: {
scheduledConfigurationChanges: true,
},
});
};
const user = () => {
testServerRoute(server, '/api/admin/user', {
user: {
isAPI: false,
id: 1,
name: 'Some User',
email: 'user@example.com',
imageUrl:
'https://gravatar.com/avatar/8aa1132e102345f8c79322340e15340?size=42&default=retro',
seenAt: '2022-11-28T14:55:18.982Z',
loginAttempts: 0,
createdAt: '2022-11-23T13:31:17.061Z',
},
permissions: [{ permission: ADMIN }],
feedback: [],
splash: {},
});
};
const changeRequestConfig = () =>
testServerRoute(
server,
'/api/admin/projects/default/change-requests/config',
[
{
environment: 'development',
type: 'development',
changeRequestEnabled: false,
},
{
environment: 'production',
type: 'production',
changeRequestEnabled: true,
},
],
'get',
);
const feature = () => {
testServerRoute(server, '/api/admin/projects/default/features/feature1', {
environments: [
{
name: 'development',
lastSeenAt: null,
variants: [],
enabled: false,
type: 'development',
sortOrder: 2,
strategies: [],
},
{
name: 'production',
lastSeenAt: null,
variants: [],
enabled: false,
type: 'production',
sortOrder: 3,
strategies: [
{
name: 'flexibleRollout',
constraints: [],
variants: [],
parameters: {
groupId: 'CR-toggle',
rollout: '100',
stickiness: 'default',
},
sortOrder: 0,
id: 'b6363cc8-ad8e-478a-b464-484bbd3b31f6',
title: '',
disabled: false,
},
],
},
],
name: 'feature1',
favorite: false,
impressionData: false,
description: null,
project: 'MyNewProject',
stale: false,
lastSeenAt: null,
createdAt: '2023-11-01T10:11:58.505Z',
type: 'release',
variants: [],
archived: false,
dependencies: [],
children: [],
});
};
const setupOtherServerRoutes = () => {
uiConfig();
changeRequestConfig();
user();
feature();
};
beforeEach(() => {
setupOtherServerRoutes();
});
const Component = () => {
return (
<>
<Routes>
<Route
path={'/projects/:projectId/features/:featureId'}
element={
<StrategyDraggableItem
strategy={strategy}
environmentName={'production'}
index={1}
onDragStartRef={vi.fn()}
onDragOver={vi.fn()}
onDragEnd={vi.fn()}
/>
}
/>
</Routes>
</>
);
};
describe('Change request badges for strategies', () => {
test('should not render a badge if no changes', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
expect(screen.queryByText('Modified in draft')).toBe(null);
expect(screen.queryByText('Changes Scheduled')).toBe(null);
});
test('should only render the "Modified in draft" badge when logged in user is the creator of change request', async () => {
const changeRequest = draftRequest('updateStrategy', 5);
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[changeRequest],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
expect(screen.queryByText('Modified in draft')).toBe(null);
});
test('should render a "Modified in draft" badge when "updateStrategy" action exists in "pending" change request', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[draftRequest('updateStrategy', 1)],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
await screen.findByText('Modified in draft');
expect(screen.queryByText('Changes Scheduled')).toBe(null);
});
test('should render a "Deleted in draft" badge when "deleteStrategy" action exists in "pending" change request', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[draftRequest('deleteStrategy', 1)],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
await screen.findByText('Deleted in draft');
expect(screen.queryByText('Changes Scheduled')).toBe(null);
});
test('should render a "Changes scheduled" badge when "updateStrategy" action exists in "Scheduled" change request', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[scheduledRequest('updateStrategy')],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
await screen.findByText('Changes Scheduled');
expect(screen.queryByText('Modified in draft')).toBe(null);
});
test('should render a "Changes Scheduled" badge when "deleteStrategy" action exists in "Scheduled" change request', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[scheduledRequest('deleteStrategy')],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
await screen.findByText('Changes Scheduled');
expect(screen.queryByText('Modified in draft')).toBe(null);
});
test('should render a both badges when "updateStrategy" action exists in "Scheduled" and pending change request', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[
scheduledRequest('updateStrategy'),
draftRequest('updateStrategy', 1),
],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
await screen.findByText('Changes Scheduled');
await screen.findByText('Modified in draft');
});
test('should render a both badges when "deleteStrategy" action exists in "Scheduled" and pending change request', async () => {
testServerRoute(
server,
'/api/admin/projects/default/change-requests/pending/feature1',
[
scheduledRequest('deleteStrategy'),
draftRequest('deleteStrategy', 1),
],
);
render(<Component />, {
route: '/projects/default/features/feature1',
permissions: [
{
permission: ADMIN,
},
],
});
await screen.findByText('Changes Scheduled');
await screen.findByText('Deleted in draft');
});
});

View File

@ -6,9 +6,13 @@ import { IFeatureEnvironment } from 'interfaces/featureToggle';
import { IFeatureStrategy } from 'interfaces/strategy';
import { StrategyItem } from './StrategyItem/StrategyItem';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { Badge } from 'component/common/Badge/Badge';
import {
useStrategyChangesFromRequest,
UseStrategyChangeFromRequestResult,
} from './StrategyItem/useStrategyChangesFromRequest';
import { ChangesScheduledBadge } from 'component/changeRequest/ModifiedInChangeRequestStatusBadge/ChangesScheduledBadge';
import { IFeatureChange } from 'component/changeRequest/changeRequest.types';
import { useStrategyChangeFromRequest } from './StrategyItem/useStrategyChangeFromRequest';
import { Badge } from 'component/common/Badge/Badge';
interface IStrategyDraggableItemProps {
strategy: IFeatureStrategy;
@ -26,6 +30,7 @@ interface IStrategyDraggableItemProps {
) => DragEventHandler<HTMLDivElement>;
onDragEnd: () => void;
}
export const StrategyDraggableItem = ({
strategy,
index,
@ -39,7 +44,7 @@ export const StrategyDraggableItem = ({
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const ref = useRef<HTMLDivElement>(null);
const change = useStrategyChangeFromRequest(
const strategyChangesFromRequest = useStrategyChangesFromRequest(
projectId,
featureId,
environmentName,
@ -65,7 +70,9 @@ export const StrategyDraggableItem = ({
onDragStart={onDragStartRef(ref, index)}
onDragEnd={onDragEnd}
orderNumber={index + 1}
headerChildren={<ChangeRequestStatusBadge change={change} />}
headerChildren={renderHeaderChildren(
strategyChangesFromRequest,
)}
/>
</Box>
);
@ -96,3 +103,36 @@ const ChangeRequestStatusBadge = ({
</Box>
);
};
const renderHeaderChildren = (
changes: UseStrategyChangeFromRequestResult,
): JSX.Element[] => {
const badges: JSX.Element[] = [];
if (changes.length === 0) {
return [];
}
const draftChange = changes.find(
({ isScheduledChange }) => !isScheduledChange,
);
if (draftChange) {
badges.push(<ChangeRequestStatusBadge change={draftChange.change} />);
}
const scheduledChanges = changes.filter(
({ isScheduledChange }) => isScheduledChange,
);
if (scheduledChanges.length > 0) {
badges.push(
<ChangesScheduledBadge
scheduledChangeRequestIds={scheduledChanges.map(
(scheduledChange) => scheduledChange.changeRequestId,
)}
/>,
);
}
return badges;
};

View File

@ -1,28 +0,0 @@
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
export const useStrategyChangeFromRequest = (
projectId: string,
featureId: string,
environment: string,
strategyId: string,
) => {
const { data } = usePendingChangeRequests(projectId);
const environmentDraft = data?.find(
(draft) => draft.environment === environment,
);
const feature = environmentDraft?.features.find(
(feature) => feature.name === featureId,
);
const change = feature?.changes.find((change) => {
if (
change.action === 'updateStrategy' ||
change.action === 'deleteStrategy'
) {
return change.payload.id === strategyId;
}
return false;
});
return change;
};

View File

@ -0,0 +1,66 @@
import { IFeatureChange } from 'component/changeRequest/changeRequest.types';
import { usePendingChangeRequestsForFeature } from 'hooks/api/getters/usePendingChangeRequestsForFeature/usePendingChangeRequestsForFeature';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
export type UseStrategyChangeFromRequestResult = Array<{
changeRequestId: number;
change: IFeatureChange;
isScheduledChange: boolean;
}>;
export const useStrategyChangesFromRequest = (
projectId: string,
featureId: string,
environment: string,
strategyId: string,
) => {
const { user } = useAuthUser();
const { changeRequests } = usePendingChangeRequestsForFeature(
projectId,
featureId,
);
const result: UseStrategyChangeFromRequestResult = [];
const environmentDraftOrScheduled = changeRequests?.filter(
(changeRequest) => changeRequest.environment === environment,
);
environmentDraftOrScheduled?.forEach((draftOrScheduled) => {
const feature = draftOrScheduled?.features.find(
(feature) => feature.name === featureId,
);
const change = feature?.changes.find((change) => {
if (
change.action === 'updateStrategy' ||
change.action === 'deleteStrategy'
) {
return change.payload.id === strategyId;
}
return false;
});
if (change) {
const isScheduledChange = draftOrScheduled.state === 'Scheduled';
const isOwnDraft =
!isScheduledChange &&
draftOrScheduled.createdBy.id === user?.id;
if (isScheduledChange) {
result.push({
changeRequestId: draftOrScheduled.id,
change,
isScheduledChange,
});
}
if (isOwnDraft) {
result.push({
changeRequestId: draftOrScheduled.id,
change,
isScheduledChange,
});
}
}
});
return result;
};