diff --git a/frontend/src/component/changeRequest/ModifiedInChangeRequestStatusBadge/ChangesScheduledBadge.tsx b/frontend/src/component/changeRequest/ModifiedInChangeRequestStatusBadge/ChangesScheduledBadge.tsx
new file mode 100644
index 000000000000..ce2d1cdc9507
--- /dev/null
+++ b/frontend/src/component/changeRequest/ModifiedInChangeRequestStatusBadge/ChangesScheduledBadge.tsx
@@ -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 (
+
+
+ {scheduledChangeRequestIds?.map((id, index) => (
+
+ Change request #{id}
+
+ ))}
+ >
+ }
+ >
+ Changes Scheduled
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.test.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.test.tsx
new file mode 100644
index 000000000000..d8b893f6e9c3
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.test.tsx
@@ -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 = '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,
+) => ({
+ ...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 (
+ <>
+
+
+ }
+ />
+
+ >
+ );
+};
+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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ route: '/projects/default/features/feature1',
+ permissions: [
+ {
+ permission: ADMIN,
+ },
+ ],
+ });
+
+ await screen.findByText('Changes Scheduled');
+ await screen.findByText('Deleted in draft');
+ });
+});
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx
index 6e4b71bd63b3..c11b69bcaed1 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem.tsx
@@ -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;
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(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={}
+ headerChildren={renderHeaderChildren(
+ strategyChangesFromRequest,
+ )}
/>
);
@@ -96,3 +103,36 @@ const ChangeRequestStatusBadge = ({
);
};
+
+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();
+ }
+
+ const scheduledChanges = changes.filter(
+ ({ isScheduledChange }) => isScheduledChange,
+ );
+
+ if (scheduledChanges.length > 0) {
+ badges.push(
+ scheduledChange.changeRequestId,
+ )}
+ />,
+ );
+ }
+
+ return badges;
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/useStrategyChangeFromRequest.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/useStrategyChangeFromRequest.tsx
deleted file mode 100644
index 8fb49395600d..000000000000
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/useStrategyChangeFromRequest.tsx
+++ /dev/null
@@ -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;
-};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/useStrategyChangesFromRequest.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/useStrategyChangesFromRequest.tsx
new file mode 100644
index 000000000000..afda632127eb
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/useStrategyChangesFromRequest.tsx
@@ -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;
+};