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:
parent
ece5a634bf
commit
100c22b42a
@ -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>
|
||||
);
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user