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; +};