From f2766b6b3b2ad0c3fc3d7e58c999d15edcab47c2 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 27 Jun 2025 12:16:11 +0200 Subject: [PATCH 01/20] Add various fixes to the CR view (#10231) Adds a number of small corrections and fixes to (mainly) CR-related component. Discovered as part of adding the new JSON diff view. Refer to the various inline comments for explanations. --------- Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> --- .../Changes/Change/ArchiveFeatureChange.tsx | 2 +- .../Changes/Change/EditChange.tsx | 3 +- .../EnvironmentStrategyExecutionOrder.tsx | 9 +- .../Changes/Change/SegmentChange.tsx | 101 +++++++++--------- .../StrategyTooltipLink.tsx | 15 ++- .../ChangeRequestHeader.styles.tsx | 16 +-- .../ChangeRequestHeader.tsx | 2 +- .../FeatureArchiveDialog.test.tsx | 8 +- .../FeatureArchiveDialog.tsx | 2 +- .../ViewableConstraintsList.tsx | 3 +- .../component/events/EventDiff/EventDiff.tsx | 5 +- 11 files changed, 95 insertions(+), 71 deletions(-) diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx index a96a034e76..9f62e99db3 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx @@ -16,7 +16,7 @@ export const ArchiveFeatureChange: FC = ({ actions, }) => ( - Archiving feature + Archiving flag {actions} ); diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx index 3cac329927..bb60ca07b9 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx @@ -26,6 +26,7 @@ import { FeatureStrategyForm } from '../../../../feature/FeatureStrategy/Feature import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants'; import { v4 as uuidv4 } from 'uuid'; import { constraintId } from 'constants/constraintId.ts'; +import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts'; interface IEditChangeProps { change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy; @@ -208,7 +209,7 @@ export const formatUpdateStrategyApiCode = ( } const url = `${unleashUrl}/api/admin/projects/${projectId}/change-requests/${changeRequestId}/changes/${changeId}`; - const payload = JSON.stringify(strategy, undefined, 2); + const payload = JSON.stringify(strategy, apiPayloadConstraintReplacer, 2); return `curl --location --request PUT '${url}' \\ --header 'Authorization: INSERT_API_KEY' \\ diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx index e122e40331..61315cb836 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx @@ -6,6 +6,7 @@ import { Box, styled } from '@mui/material'; import { EnvironmentStrategyOrderDiff } from './EnvironmentStrategyOrderDiff.tsx'; import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution'; import { formatStrategyName } from '../../../../../../utils/strategyNames.tsx'; +import type { IFeatureStrategy } from 'interfaces/strategy.ts'; const ChangeItemInfo = styled(Box)({ display: 'flex', @@ -74,14 +75,14 @@ export const EnvironmentStrategyExecutionOrder = ({ .map((strategy) => strategy.id) ?? [], }; - const updatedStrategies = change.payload + const updatedStrategies: IFeatureStrategy[] = change.payload .map(({ id }) => { return environmentStrategies.find((s) => s.id === id); }) - .filter(Boolean); + .filter((strategy): strategy is IFeatureStrategy => Boolean(strategy)); const data = { - strategyIds: updatedStrategies.map((strategy) => strategy!.id), + strategyIds: updatedStrategies.map((strategy) => strategy.id), }; return ( @@ -105,7 +106,7 @@ export const EnvironmentStrategyExecutionOrder = ({ {updatedStrategies.map((strategy, index) => ( - + {`${index + 1}: `} {formatStrategyName(strategy?.name || '')} {strategy?.title && ` - ${strategy.title}`} diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx index fadd3b334f..ad3bb088b0 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx @@ -7,6 +7,7 @@ import type { } from '../../../changeRequest.types'; import { SegmentChangeDetails } from './SegmentChangeDetails.tsx'; import { ConflictWarning } from './ConflictWarning.tsx'; +import { useSegment } from 'hooks/api/getters/useSegment/useSegment.ts'; interface ISegmentChangeProps { segmentChange: ISegmentChange; @@ -20,61 +21,65 @@ export const SegmentChange: FC = ({ onNavigate, actions, changeRequestState, -}) => ( - ({ - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - overflow: 'hidden', - })} - > - { + const { segment } = useSegment(segmentChange.payload.id); + + return ( + ({ - backgroundColor: theme.palette.neutral.light, - borderRadius: (theme) => - `${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px 0 0`, - border: '1px solid', - borderColor: (theme) => - segmentChange.conflict - ? theme.palette.warning.border - : theme.palette.divider, - borderBottom: 'none', + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), overflow: 'hidden', })} > - ({ + backgroundColor: theme.palette.neutral.light, + borderRadius: `${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px 0 0`, + border: '1px solid', + borderColor: segmentChange.conflict + ? theme.palette.warning.border + : theme.palette.divider, + borderBottom: 'none', + overflow: 'hidden', + })} > - Segment name: - - + - {segmentChange.payload.name} - + Segment name: + + + + {segmentChange.payload.name || segment?.name} + + + - - - -); + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx index 9b75425959..5d8584fb5e 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx @@ -9,13 +9,14 @@ import { formatStrategyName, GetFeatureStrategyIcon, } from 'utils/strategyNames'; -import EventDiff from 'component/events/EventDiff/EventDiff'; +import EventDiff, { NewEventDiff } from 'component/events/EventDiff/EventDiff'; import omit from 'lodash.omit'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import { Typography, styled } from '@mui/material'; import type { IFeatureStrategy } from 'interfaces/strategy'; import { textTruncated } from 'themes/themeStyles'; import { NameWithChangeInfo } from '../NameWithChangeInfo/NameWithChangeInfo.tsx'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; const StyledCodeSection = styled('div')(({ theme }) => ({ overflowX: 'auto', @@ -47,12 +48,22 @@ export const StrategyDiff: FC<{ | IChangeRequestDeleteStrategy; currentStrategy?: IFeatureStrategy; }> = ({ change, currentStrategy }) => { + const useNewDiff = useUiFlag('improvedJsonDiff'); const changeRequestStrategy = change.action === 'deleteStrategy' ? undefined : change.payload; const sortedCurrentStrategy = sortSegments(currentStrategy); const sortedChangeRequestStrategy = sortSegments(changeRequestStrategy); - + if (useNewDiff) { + return ( + + ); + } return ( ({ alignItems: 'center', })); -export const StyledHeader = styled(Typography)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - marginRight: theme.spacing(1), - fontSize: theme.fontSizes.mainHeader, -})); +export const StyledHeader = styled(Typography)( + ({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginRight: theme.spacing(1), + fontSize: theme.fontSizes.mainHeader, + }), +); export const StyledCard = styled(Card)(({ theme }) => ({ padding: theme.spacing(0.75, 1.5), diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestHeader/ChangeRequestHeader.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestHeader/ChangeRequestHeader.tsx index 25c3b19940..9563c1df3e 100644 --- a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestHeader/ChangeRequestHeader.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestHeader/ChangeRequestHeader.tsx @@ -26,7 +26,7 @@ export const ChangeRequestHeader: FC<{ changeRequest: ChangeRequestType }> = ({ title={title} setTitle={setTitle} > - + {title} diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx index 65e7a755d7..139a63e8ea 100644 --- a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx @@ -70,7 +70,7 @@ test('Add single archive feature change to change request', async () => { expect(screen.getByText('Archive feature flag')).toBeInTheDocument(); await screen.findByText( - 'Archiving features with dependencies will also remove those dependencies.', + 'Archiving flags with dependencies will also remove those dependencies.', ); const button = await screen.findByText('Add change to draft'); @@ -100,7 +100,7 @@ test('Add multiple archive feature changes to change request', async () => { await screen.findByText('Archive feature flags'); await screen.findByText( - 'Archiving features with dependencies will also remove those dependencies.', + 'Archiving flags with dependencies will also remove those dependencies.', ); const button = await screen.findByText('Add to change request'); @@ -163,7 +163,7 @@ test('Show error message when multiple parents of orphaned children are archived ); expect( screen.queryByText( - 'Archiving features with dependencies will also remove those dependencies.', + 'Archiving flags with dependencies will also remove those dependencies.', ), ).not.toBeInTheDocument(); }); @@ -189,7 +189,7 @@ test('Show error message when 1 parent of orphaned children is archived', async ); expect( screen.queryByText( - 'Archiving features with dependencies will also remove those dependencies.', + 'Archiving flags with dependencies will also remove those dependencies.', ), ).not.toBeInTheDocument(); }); diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx index 954febd1db..a9df36b147 100644 --- a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx @@ -26,7 +26,7 @@ interface IFeatureArchiveDialogProps { const RemovedDependenciesAlert = () => { return ( theme.spacing(2, 0) }}> - Archiving features with dependencies will also remove those + Archiving flags with dependencies will also remove those dependencies. ); diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList.tsx index fd2e0d7d13..48cb79ab1e 100644 --- a/frontend/src/component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList.tsx +++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList.tsx @@ -4,6 +4,7 @@ import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashCon import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList'; import { ConstraintAccordionView } from 'component/common/NewConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView'; import { constraintId } from 'constants/constraintId'; +import { objectId } from 'utils/objectId'; export interface IViewableConstraintsListProps { constraints: IConstraint[]; @@ -29,7 +30,7 @@ export const ViewableConstraintsList = ({ {constraints.map((constraint) => ( ))} diff --git a/frontend/src/component/events/EventDiff/EventDiff.tsx b/frontend/src/component/events/EventDiff/EventDiff.tsx index f5bc7bf8fd..93968bc36f 100644 --- a/frontend/src/component/events/EventDiff/EventDiff.tsx +++ b/frontend/src/component/events/EventDiff/EventDiff.tsx @@ -78,7 +78,7 @@ const ButtonIcon = styled('span')(({ theme }) => ({ marginInlineEnd: theme.spacing(0.5), })); -const NewEventDiff: FC = ({ entry, excludeKeys }) => { +export const NewEventDiff: FC = ({ entry, excludeKeys }) => { const changeType = entry.preData && entry.data ? 'edit' : 'replacement'; const showExpandButton = changeType === 'edit'; const [full, setFull] = useState(false); @@ -220,4 +220,7 @@ const EventDiff: FC = (props) => { return ; }; +/** + * @deprecated remove the default export with flag improvedJsonDiff. Switch imports in files that use this to the named import instead. + */ export default EventDiff; From 28caa82ad134925a3bfcb4d89de35894c6a26e9a Mon Sep 17 00:00:00 2001 From: David Leek Date: Mon, 30 Jun 2025 08:51:51 +0200 Subject: [PATCH 02/20] feat(changerequests): add requested approvers to overview (#10232) --- .../ChangeRequestOverview.tsx | 17 +- .../ChangeRequestRequestedApprovers.tsx | 372 ++++++++++++++++++ .../ChangeRequestReviewer.tsx | 23 +- .../DraftChangeRequestActions.tsx | 2 +- .../EnvironmentChangeRequest.tsx | 4 +- .../useChangeRequestApi.ts | 10 +- .../useAvailableChangeRequestReviewers.ts | 2 +- .../useRequestedApprovers.ts | 48 +++ 8 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx create mode 100644 frontend/src/hooks/api/getters/useRequestedApprovers/useRequestedApprovers.ts diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx index 6f2ae5534b..c1e24c886e 100644 --- a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx @@ -34,6 +34,8 @@ import { ScheduleChangeRequestDialog } from './ChangeRequestScheduledDialogs/Sch import type { PlausibleChangeRequestState } from '../changeRequest.types'; import { useNavigate } from 'react-router-dom'; import { useActionableChangeRequests } from 'hooks/api/getters/useActionableChangeRequests/useActionableChangeRequests'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; +import { ChangeRequestRequestedApprovers } from './ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx'; const StyledAsideBox = styled(Box)(({ theme }) => ({ width: '30%', @@ -106,6 +108,7 @@ export const ChangeRequestOverview: FC = () => { useChangeRequestsEnabled(projectId); const [disabled, setDisabled] = useState(false); const navigate = useNavigate(); + const approversEnabled = useUiFlag('changeRequestApproverEmails'); if (!changeRequest) { return null; @@ -288,7 +291,19 @@ export const ChangeRequestOverview: FC = () => { - + + } + elseShow={ + + } + /> diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx new file mode 100644 index 0000000000..e11899a8b2 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx @@ -0,0 +1,372 @@ +import { + Box, + Paper, + styled, + IconButton, + useTheme, + type AutocompleteChangeReason, + type FilterOptionsState, + Checkbox, + TextField, + Button, + Typography, +} from '@mui/material'; +import { + type ReviewerSchema, + useRequestedApprovers, +} from 'hooks/api/getters/useRequestedApprovers/useRequestedApprovers'; +import { useState, type FC } from 'react'; +import type { ChangeRequestType } from '../../changeRequest.types'; +import { + ChangeRequestApprover, + ChangeRequestPending, + ChangeRequestRejector, +} from '../ChangeRequestReviewers/ChangeRequestReviewer.js'; +import Add from '@mui/icons-material/Add'; +import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; +import AutocompleteVirtual from 'component/common/AutocompleteVirtual/AutcompleteVirtual.js'; +import { + type AvailableReviewerSchema, + useAvailableChangeRequestReviewers, +} from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.js'; +import { caseInsensitiveSearch } from 'utils/search.js'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi.js'; + +export const StyledSpan = styled('span')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginRight: theme.spacing(1), + fontSize: theme.fontSizes.bodySize, +})); + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + width: '100%', + '& > div': { width: '100%' }, + justifyContent: 'space-between', + marginBottom: theme.spacing(2), + marginRight: theme.spacing(-2), +})); + +const StyledIconButton = styled(IconButton)(({ theme }) => ({ + marginRight: theme.spacing(-1), +})); + +const StrechedLi = styled('li')({ width: '100%' }); + +const StyledOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:first-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const StyledTags = styled('div')(({ theme }) => ({ + paddingLeft: theme.spacing(1), +})); + +const renderOption = ( + props: React.HTMLAttributes, + option: AvailableReviewerSchema, + { selected }: { selected: boolean }, +) => ( + + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {option.name || option.username} + + {option.name && option.username + ? option.username + : option.email} + + + +); + +const renderTags = (value: AvailableReviewerSchema[]) => ( + + {value.length > 1 + ? `${value.length} reviewers` + : value[0].name || value[0].username || value[0].email} + +); + +export const ChangeRequestReviewersHeader: FC<{ + canShowAddReviewers: boolean; + showAddReviewers: boolean; + actualApprovals: number; + minApprovals: number; + setShowAddReviewers: React.Dispatch>; +}> = ({ + canShowAddReviewers, + showAddReviewers, + actualApprovals, + minApprovals, + setShowAddReviewers, +}) => { + return ( + <> + + + Reviewers + + ({actualApprovals}/{minApprovals} required) + + + {canShowAddReviewers && + (showAddReviewers ? ( + { + setShowAddReviewers(false); + }} + > + + + ) : ( + { + setShowAddReviewers(true); + }} + > + + + ))} + + + ); +}; + +export const ChangeRequestAddRequestedApprovers: FC<{ + changeRequest: Pick; + saveClicked: (reviewers: AvailableReviewerSchema[]) => void; + existingReviewers: Pick[]; +}> = ({ changeRequest, saveClicked, existingReviewers }) => { + const theme = useTheme(); + const [reviewers, setReviewers] = useState([]); + const { reviewers: fetchedReviewers, loading: isLoading } = + useAvailableChangeRequestReviewers( + changeRequest.project, + changeRequest.environment, + ); + const availableReviewers = fetchedReviewers.filter( + (reviewer) => + !existingReviewers.some((existing) => existing.id === reviewer.id), + ); + const autoCompleteChange = ( + event: React.SyntheticEvent, + newValue: AvailableReviewerSchema[], + reason: AutocompleteChangeReason, + ) => { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setReviewers(newValue); + }; + + const filterOptions = ( + options: AvailableReviewerSchema[], + { inputValue }: FilterOptionsState, + ) => + options.filter( + ({ name, username, email }) => + caseInsensitiveSearch(inputValue, email) || + caseInsensitiveSearch(inputValue, name) || + caseInsensitiveSearch(inputValue, username), + ); + + return ( + + option.id === value.id} + getOptionLabel={(option: AvailableReviewerSchema) => + option.email || option.name || option.username || '' + } + renderInput={(params) => ( + + )} + renderTags={(value) => renderTags(value)} + noOptionsText={isLoading ? 'Loading…' : 'No options'} + /> + + + ); +}; + +export const ChangeRequestRequestedApprovers: FC<{ + changeRequest: Pick< + ChangeRequestType, + | 'id' + | 'project' + | 'approvals' + | 'rejections' + | 'state' + | 'minApprovals' + | 'environment' + >; +}> = ({ changeRequest }) => { + const [showAddReviewers, setShowAddReviewers] = useState(false); + const { reviewers: requestedReviewers, refetchReviewers } = + useRequestedApprovers(changeRequest.project, changeRequest.id); + const { updateRequestedApprovers } = useChangeRequestApi(); + const canShowAddReviewers = + (changeRequest.state === 'Draft' || + changeRequest.state === 'In review') && + changeRequest.minApprovals > 0; + + let reviewers = requestedReviewers.map((reviewer) => { + const approver = changeRequest.approvals.find( + (approval) => approval.createdBy.id === reviewer.id, + ); + const rejector = changeRequest.rejections.find( + (rejection) => rejection.createdBy.id === reviewer.id, + ); + + return { + id: reviewer.id, + name: reviewer.username || reviewer.name || 'Unknown user', + imageUrl: reviewer.imageUrl, + status: approver ? 'approved' : rejector ? 'rejected' : 'pending', + }; + }); + + reviewers = [ + ...reviewers, + ...changeRequest.approvals + .filter( + (approval) => + !reviewers.find( + (r) => r.name === approval.createdBy.username, + ), + ) + .map((approval) => ({ + id: approval.createdBy.id, + name: approval.createdBy.username || 'Unknown user', + imageUrl: approval.createdBy.imageUrl, + status: 'approved', + })), + ...changeRequest.rejections + .filter( + (rejection) => + !reviewers.find( + (r) => r.name === rejection.createdBy.username, + ), + ) + .map((rejection) => ({ + id: rejection.createdBy.id, + name: rejection.createdBy.username || 'Unknown user', + imageUrl: rejection.createdBy.imageUrl, + status: 'rejected', + })), + ]; + + const saveClicked = async ( + selectedReviewers: AvailableReviewerSchema[], + ) => { + if (selectedReviewers.length > 0) { + const tosend = [ + ...reviewers.map((reviewer) => reviewer.id), + ...selectedReviewers.map((reviewer) => reviewer.id), + ]; + await updateRequestedApprovers( + changeRequest.project, + changeRequest.id, + tosend, + ); + } + refetchReviewers(); + setShowAddReviewers(false); + }; + + return ( + ({ + marginTop: theme.spacing(2), + padding: theme.spacing(4), + paddingTop: theme.spacing(2), + borderRadius: (theme) => `${theme.shape.borderRadiusLarge}px`, + })} + > + + {canShowAddReviewers && showAddReviewers && ( + + )} + {reviewers.map((reviewer) => ( + <> + {reviewer.status === 'approved' && ( + + )} + {reviewer.status === 'rejected' && ( + + )} + {reviewer.status === 'pending' && ( + + )} + + ))} + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewers/ChangeRequestReviewer.tsx b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewers/ChangeRequestReviewer.tsx index 8c20bdbea9..c98794dce5 100644 --- a/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewers/ChangeRequestReviewer.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestReviewers/ChangeRequestReviewer.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react'; import { StyledAvatar } from '../ChangeRequestHeader/ChangeRequestHeader.styles'; import CheckCircle from '@mui/icons-material/CheckCircle'; import Cancel from '@mui/icons-material/Cancel'; +import Pending from '@mui/icons-material/Pending'; interface IChangeRequestReviewerProps { name?: string; imageUrl?: string; @@ -26,6 +27,11 @@ export const StyledErrorIcon = styled(Cancel)(({ theme }) => ({ marginLeft: 'auto', })); +export const StyledPendingIcon = styled(Pending)(({ theme }) => ({ + color: theme.palette.neutral.main, + marginLeft: 'auto', +})); + export const ReviewerName = styled(Typography)({ maxWidth: '170px', textOverflow: 'ellipsis', @@ -39,7 +45,7 @@ export const ChangeRequestApprover: FC = ({ imageUrl, }) => { return ( - + {name} @@ -52,10 +58,23 @@ export const ChangeRequestRejector: FC = ({ imageUrl, }) => { return ( - + {name} ); }; + +export const ChangeRequestPending: FC = ({ + name, + imageUrl, +}) => { + return ( + + + {name} + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx index da1bb88b26..155c535058 100644 --- a/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx @@ -74,7 +74,7 @@ const renderOption = ( const renderTags = (value: AvailableReviewerSchema[]) => ( {value.length > 1 - ? `${value.length} users selected` + ? `${value.length} reviewers` : value[0].name || value[0].username || value[0].email} ); diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx index 3d48ecae1b..d57f103a38 100644 --- a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx @@ -74,7 +74,7 @@ export const EnvironmentChangeRequest: FC<{ const [commentText, setCommentText] = useState(''); const { user } = useAuthUser(); const [title, setTitle] = useState(environmentChangeRequest.title); - const { changeState, updateRequestedReviewers } = useChangeRequestApi(); + const { changeState, updateRequestedApprovers } = useChangeRequestApi(); const [reviewers, setReviewers] = useState([]); const [disabled, setDisabled] = useState(false); @@ -83,7 +83,7 @@ export const EnvironmentChangeRequest: FC<{ setDisabled(true); try { if (reviewers && reviewers.length > 0) { - await updateRequestedReviewers( + await updateRequestedApprovers( project, environmentChangeRequest.id, reviewers.map((reviewer) => reviewer.id), diff --git a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts index a8cd0a0485..8863024ef6 100644 --- a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts +++ b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts @@ -190,17 +190,17 @@ export const useChangeRequestApi = () => { return makeRequest(req.caller, req.id); }; - const updateRequestedReviewers = async ( + const updateRequestedApprovers = async ( project: string, changeRequestId: number, - reviewers: string[], + reviewers: number[], ) => { trackEvent('change_request', { props: { - eventType: 'reviewers updated', + eventType: 'approvers updated', }, }); - const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/reviewers`; + const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/approvers`; const req = createRequest(path, { method: 'PUT', body: JSON.stringify({ reviewers }), @@ -217,7 +217,7 @@ export const useChangeRequestApi = () => { discardDraft, addComment, updateTitle, - updateRequestedReviewers, + updateRequestedApprovers, errors, loading, }; diff --git a/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts b/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts index 00c83131d0..347c76db36 100644 --- a/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts +++ b/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts @@ -5,7 +5,7 @@ import handleErrorResponses from '../httpErrorResponseHandler.js'; // TODO: These will likely be created by Orval next time it is run export interface AvailableReviewerSchema { - id: string; + id: number; name?: string; email: string; username?: string; diff --git a/frontend/src/hooks/api/getters/useRequestedApprovers/useRequestedApprovers.ts b/frontend/src/hooks/api/getters/useRequestedApprovers/useRequestedApprovers.ts new file mode 100644 index 0000000000..be6488f098 --- /dev/null +++ b/frontend/src/hooks/api/getters/useRequestedApprovers/useRequestedApprovers.ts @@ -0,0 +1,48 @@ +import useSWR from 'swr'; +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler.js'; + +// TODO: These will likely be created by Orval next time it is run +export interface ReviewerSchema { + id: number; + name?: string; + email: string; + username?: string; + imageUrl?: string; +} + +export interface IReviewersResponse { + reviewers: ReviewerSchema[]; + refetchReviewers: () => void; + loading: boolean; + error?: Error; +} + +export const useRequestedApprovers = ( + project: string, + changeRequestId: number, +): IReviewersResponse => { + const { data, error, mutate } = useSWR( + formatApiPath( + `api/admin/projects/${project}/change-requests/${changeRequestId}/approvers`, + ), + fetcher, + ); + + return useMemo( + () => ({ + reviewers: data?.reviewers || [], + loading: !error && !data, + refetchReviewers: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Requested Approvers')) + .then((res) => res.json()); +}; From c5ddcdbc3cc7fb8a40613e8c64d49c02485dae95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 30 Jun 2025 08:14:23 +0100 Subject: [PATCH 03/20] chore: allow backdrop click through on AddValuesPopover (#10214) Follow-up to: https://github.com/Unleash/unleash/pull/10213 This makes our `AddValuesPopover` backdrop click-through. This means you can interact with any element in the "background" right away and it will work, while closing the popover at the same time. If this works well it may be worth extracting to a reusable ClickThroughPopover or similar. --- .../EditableConstraint/AddValuesPopover.tsx | 117 ++++++++++-------- 1 file changed, 64 insertions(+), 53 deletions(-) diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx index 6c84e92b86..6da67aa321 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx @@ -1,5 +1,6 @@ import { Button, + ClickAwayListener, type InputBaseComponentProps, Popover, styled, @@ -15,6 +16,14 @@ const StyledPopover = styled(Popover)(({ theme }) => ({ padding: theme.spacing(2), width: '250px', }, + + '&.MuiPopover-root': { + pointerEvents: 'none', + }, + + '& .MuiPopover-paper': { + pointerEvents: 'all', + }, })); const StyledTextField = styled(TextField)(({ theme }) => ({ @@ -92,59 +101,61 @@ export const AddValuesPopover: FC = ({ horizontal: 'left', }} > -
{ - e.stopPropagation(); - e.preventDefault(); - if (!inputValue?.trim()) { - setError('Value cannot be empty or whitespace'); - return; - } else { - onAdd(inputValue, { - setError, - clearInput: () => setInputValue(''), - }); - } - }} - > - - - - - { - setInputValue(e.target.value); - setError(''); - }} - size='small' - variant='standard' - fullWidth - inputRef={inputRef} - autoFocus - error={!!error} - helperText={error} - aria-describedby={helpTextId} - inputProps={{ - ...inputProps, - }} - data-testid='CONSTRAINT_VALUES_INPUT' - /> - - Add - - - {helpText} -
+ +
{ + e.stopPropagation(); + e.preventDefault(); + if (!inputValue?.trim()) { + setError('Value cannot be empty or whitespace'); + return; + } else { + onAdd(inputValue, { + setError, + clearInput: () => setInputValue(''), + }); + } + }} + > + + + + + { + setInputValue(e.target.value); + setError(''); + }} + size='small' + variant='standard' + fullWidth + inputRef={inputRef} + autoFocus + error={!!error} + helperText={error} + aria-describedby={helpTextId} + inputProps={{ + ...inputProps, + }} + data-testid='CONSTRAINT_VALUES_INPUT' + /> + + Add + + + {helpText} +
+
); }; From 39cdc170f25b525d8ceddca89056062b97d420a1 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:48:00 +0200 Subject: [PATCH 04/20] Feat: impact metrics fronted (#10182) --- frontend/src/component/insights/Insights.tsx | 4 + .../components/LineChart/LineChart.tsx | 11 +- .../insights/impact-metrics/ImpactMetrics.tsx | 216 ++++++++++++++++++ .../impact-metrics/ImpactMetricsControls.tsx | 203 ++++++++++++++++ .../impact-metrics/hooks/useChartData.ts | 85 +++++++ .../impact-metrics/hooks/useSeriesColor.ts | 17 ++ .../insights/impact-metrics/utils.ts | 60 +++++ .../useImpactMetricsData.ts | 80 +++++++ .../useImpactMetricsMetadata.ts | 26 +++ frontend/src/interfaces/uiConfig.ts | 1 + 10 files changed, 698 insertions(+), 5 deletions(-) create mode 100644 frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx create mode 100644 frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx create mode 100644 frontend/src/component/insights/impact-metrics/hooks/useChartData.ts create mode 100644 frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts create mode 100644 frontend/src/component/insights/impact-metrics/utils.ts create mode 100644 frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts create mode 100644 frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts diff --git a/frontend/src/component/insights/Insights.tsx b/frontend/src/component/insights/Insights.tsx index 76ab5b3e92..f5936916d9 100644 --- a/frontend/src/component/insights/Insights.tsx +++ b/frontend/src/component/insights/Insights.tsx @@ -7,16 +7,20 @@ import { StyledContainer } from './InsightsCharts.styles.ts'; import { LifecycleInsights } from './sections/LifecycleInsights.tsx'; import { PerformanceInsights } from './sections/PerformanceInsights.tsx'; import { UserInsights } from './sections/UserInsights.tsx'; +import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx'; const StyledWrapper = styled('div')(({ theme }) => ({ paddingTop: theme.spacing(2), })); const NewInsights: FC = () => { + const impactMetricsEnabled = useUiFlag('impactMetrics'); + return ( + {impactMetricsEnabled ? : null} diff --git a/frontend/src/component/insights/components/LineChart/LineChart.tsx b/frontend/src/component/insights/components/LineChart/LineChart.tsx index a0262bcb72..98132bedfc 100644 --- a/frontend/src/component/insights/components/LineChart/LineChart.tsx +++ b/frontend/src/component/insights/components/LineChart/LineChart.tsx @@ -27,7 +27,10 @@ export const fillGradientPrimary = fillGradient( 'rgba(129, 122, 254, 0.12)', ); -export const NotEnoughData = () => ( +export const NotEnoughData = ({ + title = 'Not enough data', + description = 'Two or more weeks of data are needed to show a chart.', +}) => ( <> ( paddingBottom: theme.spacing(1), })} > - Not enough data - - - Two or more weeks of data are needed to show a chart. + {title} + {description} ); diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx new file mode 100644 index 0000000000..816cb4521e --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx @@ -0,0 +1,216 @@ +import type { FC } from 'react'; +import { useMemo, useState } from 'react'; +import { Box, Typography, Alert } from '@mui/material'; +import { + LineChart, + NotEnoughData, +} from '../components/LineChart/LineChart.tsx'; +import { InsightsSection } from '../sections/InsightsSection.tsx'; +import { + StyledChartContainer, + StyledWidget, + StyledWidgetStats, +} from 'component/insights/InsightsCharts.styles'; +import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { usePlaceholderData } from '../hooks/usePlaceholderData.js'; +import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; +import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; +import { fromUnixTime } from 'date-fns'; +import { useChartData } from './hooks/useChartData.ts'; + +export const ImpactMetrics: FC = () => { + const [selectedSeries, setSelectedSeries] = useState(''); + const [selectedRange, setSelectedRange] = useState< + 'hour' | 'day' | 'week' | 'month' + >('day'); + const [beginAtZero, setBeginAtZero] = useState(false); + const [selectedLabels, setSelectedLabels] = useState< + Record + >({}); + + const handleSeriesChange = (series: string) => { + setSelectedSeries(series); + setSelectedLabels({}); // labels are series-specific + }; + + const { + metadata, + loading: metadataLoading, + error: metadataError, + } = useImpactMetricsMetadata(); + const { + data: { start, end, series: timeSeriesData, labels: availableLabels }, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData( + selectedSeries + ? { + series: selectedSeries, + range: selectedRange, + labels: + Object.keys(selectedLabels).length > 0 + ? selectedLabels + : undefined, + } + : undefined, + ); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const metricSeries = useMemo(() => { + if (!metadata?.series) { + return []; + } + return Object.entries(metadata.series).map(([name, rest]) => ({ + name, + ...rest, + })); + }, [metadata]); + + const data = useChartData(timeSeriesData); + + const hasError = metadataError || dataError; + const isLoading = metadataLoading || dataLoading; + const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; + const notEnoughData = useMemo( + () => + !isLoading && + (!timeSeriesData || + timeSeriesData.length === 0 || + !data.datasets.some((d) => d.data.length > 1)), + [data, isLoading, timeSeriesData], + ); + + const minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = selectedSeries ? ( + + ) : ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + return ( + + + + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + width: '100%', + })} + > + + + {!selectedSeries && !isLoading ? ( + + Select a metric series to view the chart + + ) : null} + + + + + {hasError ? ( + + Failed to load impact metrics. Please check if + Prometheus is configured and the feature flag is + enabled. + + ) : null} + + typeof value === 'number' + ? formatLargeNumbers( + value, + ) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: + timeSeriesData && + timeSeriesData.length > 1, + position: 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + } + } + cover={cover} + /> + + + + ); +}; diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx new file mode 100644 index 0000000000..e658970d7c --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx @@ -0,0 +1,203 @@ +import type { FC } from 'react'; +import { + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Box, + Autocomplete, + TextField, + Typography, + Chip, +} from '@mui/material'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; + +export interface ImpactMetricsControlsProps { + selectedSeries: string; + onSeriesChange: (series: string) => void; + selectedRange: 'hour' | 'day' | 'week' | 'month'; + onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void; + beginAtZero: boolean; + onBeginAtZeroChange: (beginAtZero: boolean) => void; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; + selectedLabels: Record; + onLabelsChange: (labels: Record) => void; + availableLabels?: ImpactMetricsLabels; +} + +export const ImpactMetricsControls: FC = ({ + selectedSeries, + onSeriesChange, + selectedRange, + onRangeChange, + beginAtZero, + onBeginAtZeroChange, + metricSeries, + loading = false, + selectedLabels, + onLabelsChange, + availableLabels, +}) => { + const handleLabelChange = (labelKey: string, values: string[]) => { + const newLabels = { ...selectedLabels }; + if (values.length === 0) { + delete newLabels[labelKey]; + } else { + newLabels[labelKey] = values; + } + onLabelsChange(newLabels); + }; + + const clearAllLabels = () => { + onLabelsChange({}); + }; + + return ( + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + maxWidth: 400, + })} + > + + Select a custom metric to see its value over time. This can help + you understand the impact of your feature rollout on key + outcomes, such as system performance, usage patterns or error + rates. + + + option.name} + value={ + metricSeries.find( + (option) => option.name === selectedSeries, + ) || null + } + onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} + disabled={loading} + renderOption={(props, option, { inputValue }) => ( + + + + + {option.name} + + + + + {option.help} + + + + + )} + renderInput={(params) => ( + + )} + noOptionsText='No metrics available' + sx={{ minWidth: 300 }} + /> + + + Time + + + + onBeginAtZeroChange(e.target.checked)} + /> + } + label='Begin at zero' + /> + {availableLabels && Object.keys(availableLabels).length > 0 ? ( + + + + Filter by labels + + {Object.keys(selectedLabels).length > 0 && ( + + )} + + + {Object.entries(availableLabels).map( + ([labelKey, values]) => ( + + handleLabelChange(labelKey, newValues) + } + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...chipProps } = + getTagProps({ index }); + return ( + + ); + }) + } + renderInput={(params) => ( + + )} + sx={{ minWidth: 300 }} + /> + ), + )} + + ) : null} + + ); +}; diff --git a/frontend/src/component/insights/impact-metrics/hooks/useChartData.ts b/frontend/src/component/insights/impact-metrics/hooks/useChartData.ts new file mode 100644 index 0000000000..ea3e8356db --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/hooks/useChartData.ts @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { useTheme } from '@mui/material'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { useSeriesColor } from './useSeriesColor.ts'; +import { getSeriesLabel } from '../utils.ts'; + +export const useChartData = ( + timeSeriesData: ImpactMetricsSeries[] | undefined, +) => { + const theme = useTheme(); + const getSeriesColor = useSeriesColor(); + + return useMemo(() => { + if (!timeSeriesData || timeSeriesData.length === 0) { + return { + labels: [], + datasets: [ + { + data: [], + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.primary.light, + }, + ], + }; + } + + if (timeSeriesData.length === 1) { + const series = timeSeriesData[0]; + const timestamps = series.data.map( + ([epochTimestamp]) => new Date(epochTimestamp * 1000), + ); + const values = series.data.map(([, value]) => value); + + return { + labels: timestamps, + datasets: [ + { + data: values, + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.primary.light, + label: getSeriesLabel(series.metric), + }, + ], + }; + } else { + const allTimestamps = new Set(); + timeSeriesData.forEach((series) => { + series.data.forEach(([timestamp]) => { + allTimestamps.add(timestamp); + }); + }); + const sortedTimestamps = Array.from(allTimestamps).sort( + (a, b) => a - b, + ); + const labels = sortedTimestamps.map( + (timestamp) => new Date(timestamp * 1000), + ); + + const datasets = timeSeriesData.map((series) => { + const seriesLabel = getSeriesLabel(series.metric); + const color = getSeriesColor(seriesLabel); + + const dataMap = new Map(series.data); + + const data = sortedTimestamps.map( + (timestamp) => dataMap.get(timestamp) ?? null, + ); + + return { + label: seriesLabel, + data, + borderColor: color, + backgroundColor: color, + fill: false, + spanGaps: false, + }; + }); + + return { + labels, + datasets, + }; + } + }, [timeSeriesData, theme, getSeriesColor]); +}; diff --git a/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts b/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts new file mode 100644 index 0000000000..a8a0f3f6e5 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts @@ -0,0 +1,17 @@ +import { useTheme } from '@mui/material'; + +export const useSeriesColor = () => { + const theme = useTheme(); + const colors = theme.palette.charts.series; + + return (seriesLabel: string): string => { + let hash = 0; + for (let i = 0; i < seriesLabel.length; i++) { + const char = seriesLabel.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + const index = Math.abs(hash) % colors.length; + return colors[index]; + }; +}; diff --git a/frontend/src/component/insights/impact-metrics/utils.ts b/frontend/src/component/insights/impact-metrics/utils.ts new file mode 100644 index 0000000000..8c4e292d39 --- /dev/null +++ b/frontend/src/component/insights/impact-metrics/utils.ts @@ -0,0 +1,60 @@ +export const getTimeUnit = (selectedRange: string) => { + switch (selectedRange) { + case 'hour': + return 'minute'; + case 'day': + return 'hour'; + case 'week': + return 'day'; + case 'month': + return 'day'; + default: + return 'hour'; + } +}; + +export const getDisplayFormat = (selectedRange: string) => { + switch (selectedRange) { + case 'hour': + case 'day': + return 'HH:mm'; + case 'week': + case 'month': + return 'MMM dd'; + default: + return 'MMM dd HH:mm'; + } +}; + +export const getSeriesLabel = (metric: Record): string => { + const { __name__, ...labels } = metric; + + const labelParts = Object.entries(labels) + .filter(([key, value]) => key !== '__name__' && value) + .map(([key, value]) => `${key}=${value}`) + .join(', '); + + if (!__name__ && !labelParts) { + return 'Series'; + } + + if (!__name__) { + return labelParts; + } + + if (!labelParts) { + return __name__; + } + + return `${__name__} (${labelParts})`; +}; + +export const formatLargeNumbers = (value: number): string => { + if (value >= 1000000) { + return `${(value / 1000000).toFixed(0)}M`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(0)}k`; + } + return value.toString(); +}; diff --git a/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts new file mode 100644 index 0000000000..930611435b --- /dev/null +++ b/frontend/src/hooks/api/getters/useImpactMetricsData/useImpactMetricsData.ts @@ -0,0 +1,80 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +import { formatApiPath } from 'utils/formatPath'; + +export type TimeSeriesData = [number, number][]; + +export type ImpactMetricsLabels = Record; + +export type ImpactMetricsSeries = { + metric: Record; + data: TimeSeriesData; +}; + +export type ImpactMetricsResponse = { + start?: string; + end?: string; + step?: string; + series: ImpactMetricsSeries[]; + labels?: ImpactMetricsLabels; +}; + +export type ImpactMetricsQuery = { + series: string; + range: 'hour' | 'day' | 'week' | 'month'; + labels?: Record; +}; + +export const useImpactMetricsData = (query?: ImpactMetricsQuery) => { + const shouldFetch = Boolean(query?.series && query?.range); + + const createPath = () => { + if (!query) return ''; + const params = new URLSearchParams({ + series: query.series, + range: query.range, + }); + + if (query.labels && Object.keys(query.labels).length > 0) { + // Send labels as they are - the backend will handle the formatting + const labelsParam = Object.entries(query.labels).reduce( + (acc, [key, values]) => { + if (values.length > 0) { + acc[key] = values; + } + return acc; + }, + {} as Record, + ); + + if (Object.keys(labelsParam).length > 0) { + params.append('labels', JSON.stringify(labelsParam)); + } + } + + return `api/admin/impact-metrics/?${params.toString()}`; + }; + + const PATH = createPath(); + + const { data, refetch, loading, error } = + useApiGetter( + shouldFetch ? formatApiPath(PATH) : null, + shouldFetch + ? () => fetcher(formatApiPath(PATH), 'Impact metrics data') + : () => Promise.resolve([]), + { + refreshInterval: 30 * 1_000, + revalidateOnFocus: true, + }, + ); + + return { + data: data || { + series: [], + labels: {}, + }, + refetch, + loading: shouldFetch ? loading : false, + error, + }; +}; diff --git a/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts new file mode 100644 index 0000000000..a664f8fa07 --- /dev/null +++ b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts @@ -0,0 +1,26 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +import { formatApiPath } from 'utils/formatPath'; + +export type ImpactMetricsSeries = { + type: string; + help: string; +}; + +export type ImpactMetricsMetadata = { + series: Record; +}; + +export const useImpactMetricsMetadata = () => { + const PATH = `api/admin/impact-metrics/metadata`; + const { data, refetch, loading, error } = + useApiGetter(formatApiPath(PATH), () => + fetcher(formatApiPath(PATH), 'Impact metrics metadata'), + ); + + return { + metadata: data, + refetch, + loading, + error, + }; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 501a7ab46b..a622aed5e2 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -90,6 +90,7 @@ export type UiFlags = { createFlagDialogCache?: boolean; healthToTechDebt?: boolean; improvedJsonDiff?: boolean; + impactMetrics?: boolean; crDiffView?: boolean; changeRequestApproverEmails?: boolean; }; From 8ade5b5dbb55597d702aa874c575009bed08ab30 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 11:04:44 +0200 Subject: [PATCH 05/20] feat: add tab switcher for change to json diff view in CR (#10179) Updates the strategy change component used in change requests to have a "tab"-like functionality, where you can switch between "Change" (the rendered strategy) and "View diff" (the JSON diff). This is a tab switcher, so you navigate it with arrow keys if on a keyboard, and I've set selection to follow focus for now. This is my preference as it saves you extra space/enter taps, but we can remove it later if we want. Most of the changes in this PR is wrapping the existing strategy change components/sections in tab panels and tab context providers. Later changes in this project will remove the existing "view diff" hover link in favor of this view. There is some work to do on the design front (in terms of spacing, borders, etc), but this covers the core functionality. The pre-existing strategy change component has been moved into the "LegacyStrategyChange" file with no changes beyond a deprecation comment. The existing tests now test the new component instead with no breakage. (I anticipate breaking when we remove the view diff link, though) Change with strategy variants: image image ## Edge case: deleted strat with no reference strategy There is a case in the code where "reference strategy" can be undefined for a deleted strategy. It is defined as follows: ```ts const referenceStrategy = changeRequestState === 'Applied' ? change.payload.snapshot : currentStrategy; ``` I've decided to still show the "(no changes)" json diff in that setting, so that the tabs actually affect something. I don't know how often this happens, though: probably not anymore unless your CR is _ancient_, as we introduced the snapshot quite a while ago, and `currentStrategy` shouldn't really be undefined here, I should think. Deleted strategy with no reference strategy: Change: image Diff: image --- .../Changes/Change/FeatureChange.tsx | 11 +- .../Changes/Change/LegacyStrategyChange.tsx | 357 ++++++++++++++++++ .../Changes/Change/StrategyChange.tsx | 170 ++++++--- .../StrategyTooltipLink.tsx | 8 +- 4 files changed, 499 insertions(+), 47 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/LegacyStrategyChange.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index 125ba4c5dd..d3c90457a3 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -8,13 +8,15 @@ import { objectId } from 'utils/objectId'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { Alert, Box, styled } from '@mui/material'; import { ToggleStatusChange } from './ToggleStatusChange.tsx'; -import { StrategyChange } from './StrategyChange.tsx'; +import { LegacyStrategyChange } from './LegacyStrategyChange.tsx'; import { VariantPatch } from './VariantPatch/VariantPatch.tsx'; import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx'; import { ArchiveFeatureChange } from './ArchiveFeatureChange.tsx'; import { DependencyChange } from './DependencyChange.tsx'; import { Link } from 'react-router-dom'; import { ReleasePlanChange } from './ReleasePlanChange.tsx'; +import { StrategyChange } from './StrategyChange.tsx'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; const StyledSingleChangeBox = styled(Box, { shouldForwardProp: (prop: string) => !prop.startsWith('$'), @@ -87,6 +89,11 @@ export const FeatureChange: FC<{ ? feature.changes.length + 1 : feature.changes.length; + const useDiffableChangeComponent = useUiFlag('crDiffView'); + const StrategyChangeComponent = useDiffableChangeComponent + ? StrategyChange + : LegacyStrategyChange; + return ( ({ + display: 'grid', + gridTemplateColumns: 'auto auto', + justifyContent: 'space-between', + gap: theme.spacing(1), + alignItems: 'center', + marginBottom: theme.spacing(2), + width: '100%', +})); + +const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)( + ({ theme }) => ({ + display: 'grid', + gridTemplateColumns: '150px auto', + gridAutoFlow: 'column', + alignItems: 'center', + flexGrow: 1, + gap: theme.spacing(1), + }), +); + +const StyledBox: FC<{ children?: React.ReactNode }> = styled(Box)( + ({ theme }) => ({ + marginTop: theme.spacing(2), + }), +); + +const StyledTypography: FC<{ children?: React.ReactNode }> = styled(Typography)( + ({ theme }) => ({ + margin: `${theme.spacing(1)} 0`, + }), +); + +const DisabledEnabledState: FC<{ show?: boolean; disabled: boolean }> = ({ + show = true, + disabled, +}) => { + if (!show) { + return null; + } + + if (disabled) { + return ( + + }> + Disabled + + + ); + } + + return ( + + }> + Enabled + + + ); +}; + +const EditHeader: FC<{ + wasDisabled?: boolean; + willBeDisabled?: boolean; +}> = ({ wasDisabled = false, willBeDisabled = false }) => { + if (wasDisabled && willBeDisabled) { + return ( + Editing strategy: + ); + } + + if (!wasDisabled && willBeDisabled) { + return Editing strategy:; + } + + if (wasDisabled && !willBeDisabled) { + return Editing strategy:; + } + + return Editing strategy:; +}; + +const hasDiff = (object: unknown, objectToCompare: unknown) => + JSON.stringify(object) !== JSON.stringify(objectToCompare); + +const DeleteStrategy: FC<{ + change: IChangeRequestDeleteStrategy; + changeRequestState: ChangeRequestState; + currentStrategy: IFeatureStrategy | undefined; + actions?: ReactNode; +}> = ({ change, changeRequestState, currentStrategy, actions }) => { + const name = + changeRequestState === 'Applied' + ? change.payload?.snapshot?.name + : currentStrategy?.name; + const title = + changeRequestState === 'Applied' + ? change.payload?.snapshot?.title + : currentStrategy?.title; + const referenceStrategy = + changeRequestState === 'Applied' + ? change.payload.snapshot + : currentStrategy; + + return ( + <> + + + ({ + color: theme.palette.error.main, + })} + > + - Deleting strategy: + + + + + +
{actions}
+
+ {referenceStrategy && ( + + )} + + ); +}; + +const UpdateStrategy: FC<{ + change: IChangeRequestUpdateStrategy; + changeRequestState: ChangeRequestState; + currentStrategy: IFeatureStrategy | undefined; + actions?: ReactNode; +}> = ({ change, changeRequestState, currentStrategy, actions }) => { + const previousTitle = + changeRequestState === 'Applied' + ? change.payload.snapshot?.title + : currentStrategy?.title; + const referenceStrategy = + changeRequestState === 'Applied' + ? change.payload.snapshot + : currentStrategy; + const hasVariantDiff = hasDiff( + referenceStrategy?.variants || [], + change.payload.variants || [], + ); + + return ( + <> + + + + + + + + +
{actions}
+
+ theme.spacing(2), + marginBottom: (theme) => theme.spacing(2), + ...flexRow, + gap: (theme) => theme.spacing(1), + }} + > + This strategy will be{' '} + + + } + /> + + {hasVariantDiff ? ( + + {change.payload.variants?.length ? ( + <> + + {currentStrategy?.variants?.length + ? 'Updating strategy variants to:' + : 'Adding strategy variants:'} + + + + ) : ( + + Removed all strategy variants. + + )} + + ) : null} + + ); +}; + +const AddStrategy: FC<{ + change: IChangeRequestAddStrategy; + actions?: ReactNode; +}> = ({ change, actions }) => ( + <> + + + + + Adding strategy: + + + + +
+ +
+
+
{actions}
+
+ + {change.payload.variants?.length ? ( + + Adding strategy variants: + + + ) : null} + +); + +/** + * Deprecated: use StrategyChange instead. Remove file with flag crDiffView + * @deprecated + */ +export const LegacyStrategyChange: FC<{ + actions?: ReactNode; + change: + | IChangeRequestAddStrategy + | IChangeRequestDeleteStrategy + | IChangeRequestUpdateStrategy; + environmentName: string; + featureName: string; + projectId: string; + changeRequestState: ChangeRequestState; +}> = ({ + actions, + change, + featureName, + environmentName, + projectId, + changeRequestState, +}) => { + const currentStrategy = useCurrentStrategy( + change, + projectId, + featureName, + environmentName, + ); + + return ( + <> + {change.action === 'addStrategy' && ( + + )} + {change.action === 'deleteStrategy' && ( + + )} + {change.action === 'updateStrategy' && ( + + )} + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx index edab2487fc..639e06672e 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx @@ -1,6 +1,14 @@ import type React from 'react'; import type { FC, ReactNode } from 'react'; -import { Box, styled, Tooltip, Typography } from '@mui/material'; +import { + Box, + Button, + type ButtonProps, + styled, + Tooltip, + Typography, +} from '@mui/material'; +import { Tab, Tabs, TabsList, TabPanel } from '@mui/base'; import BlockIcon from '@mui/icons-material/Block'; import TrackChangesIcon from '@mui/icons-material/TrackChanges'; import { @@ -29,12 +37,10 @@ export const ChangeItemWrapper = styled(Box)({ }); const ChangeItemCreateEditDeleteWrapper = styled(Box)(({ theme }) => ({ - display: 'grid', - gridTemplateColumns: 'auto auto', + display: 'flex', justifyContent: 'space-between', gap: theme.spacing(1), alignItems: 'center', - marginBottom: theme.spacing(2), width: '100%', })); @@ -157,15 +163,63 @@ const DeleteStrategy: FC<{ /> -
{actions}
+ {actions} - {referenceStrategy && ( - - )} + + {referenceStrategy && ( + + )} + + + + ); }; +const ActionsContainer = styled('div')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', +})); + +const StyledTabList = styled(TabsList)(({ theme }) => ({ + display: 'inline-flex', + flexDirection: 'row', + gap: theme.spacing(0.5), +})); + +const StyledButton = styled(Button)(({ theme }) => ({ + whiteSpace: 'nowrap', + color: theme.palette.text.secondary, + fontWeight: 'normal', + '&[aria-selected="true"]': { + fontWeight: 'bold', + color: theme.palette.primary.main, + background: theme.palette.background.elevation1, + }, +})); + +export const StyledTab = styled(({ children }: ButtonProps) => ( + {children} +))(({ theme }) => ({ + position: 'absolute', + top: theme.spacing(-0.5), + left: theme.spacing(2), + transform: 'translateY(-50%)', + padding: theme.spacing(0.75, 1), + lineHeight: 1, + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.primary, + background: theme.palette.background.application, + borderRadius: theme.shape.borderRadiusExtraLarge, + zIndex: theme.zIndex.fab, + textTransform: 'uppercase', +})); + const UpdateStrategy: FC<{ change: IChangeRequestUpdateStrategy; changeRequestState: ChangeRequestState; @@ -212,7 +266,7 @@ const UpdateStrategy: FC<{ /> -
{actions}
+ {actions} } /> - - {hasVariantDiff ? ( - - {change.payload.variants?.length ? ( - <> + + + + {hasVariantDiff ? ( + + {change.payload.variants?.length ? ( + <> + + {currentStrategy?.variants?.length + ? 'Updating strategy variants to:' + : 'Adding strategy variants:'} + + + + ) : ( - {currentStrategy?.variants?.length - ? 'Updating strategy variants to:' - : 'Adding strategy variants:'} + Removed all strategy variants. - - - ) : ( - - Removed all strategy variants. - - )} - - ) : null} + )} + + ) : null} + + + + ); }; @@ -288,17 +351,24 @@ const AddStrategy: FC<{ /> -
{actions}
+ {actions} - - {change.payload.variants?.length ? ( - - Adding strategy variants: - - - ) : null} + + + {change.payload.variants?.length ? ( + + + Adding strategy variants: + + + + ) : null} + + + + ); @@ -327,17 +397,31 @@ export const StrategyChange: FC<{ environmentName, ); + const Actions = ( + + + Change + View diff + + {actions} + + ); + return ( - <> + {change.action === 'addStrategy' && ( - + )} {change.action === 'deleteStrategy' && ( )} {change.action === 'updateStrategy' && ( @@ -345,9 +429,9 @@ export const StrategyChange: FC<{ change={change} changeRequestState={changeRequestState} currentStrategy={currentStrategy} - actions={actions} + actions={Actions} /> )} - + ); }; diff --git a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx index 5d8584fb5e..d6f7dd15c1 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx @@ -58,8 +58,12 @@ export const StrategyDiff: FC<{ return ( ); From 2d2ba4ae25c5f1bf8634869fdb4e2738b269589a Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 30 Jun 2025 12:50:51 +0300 Subject: [PATCH 06/20] feat: start storing event group type and id (#10233) Simple 2 columns for event type and event id. I thought about adding check for type, but decided to handle checking in backend code. Currently the types will be 1. transaction 2. change-request And ID is ulid --- ...630114145-add-transaction-context-to-events.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/migrations/20250630114145-add-transaction-context-to-events.js diff --git a/src/migrations/20250630114145-add-transaction-context-to-events.js b/src/migrations/20250630114145-add-transaction-context-to-events.js new file mode 100644 index 0000000000..4f010f8e18 --- /dev/null +++ b/src/migrations/20250630114145-add-transaction-context-to-events.js @@ -0,0 +1,15 @@ +exports.up = function(db, cb) { + db.runSql(` + ALTER TABLE events + ADD COLUMN IF NOT EXISTS group_type TEXT, + ADD COLUMN IF NOT EXISTS group_id TEXT; + `, cb); +}; + +exports.down = function(db, cb) { + db.runSql(` + ALTER TABLE events + DROP COLUMN IF EXISTS group_id, + DROP COLUMN IF EXISTS group_type; + `, cb); +}; \ No newline at end of file From e2bb894f6805e0eef6a78697c68460f43b9ccdc8 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 11:53:21 +0200 Subject: [PATCH 07/20] feat(1-3878)/diffable segment changes (#10234) Adds change / view diff tab buttons to segment changes too. Extracts the tab definitions and stylings into its own file so that it's easier to share across CR change components. Moves the old segment change details into the legacy segment change file. Change views: image Diff views: image --- .../Changes/Change/ChangeTabComponents.tsx | 54 +++++++ .../Change/LegacySegmentChangeDetails.tsx | 129 +++++++++++++++++ .../Changes/Change/SegmentChange.tsx | 8 +- .../Changes/Change/SegmentChangeDetails.tsx | 132 +++++++++++------- .../Changes/Change/StrategyChange.tsx | 78 ++--------- .../ChangeRequest/SegmentTooltipLink.tsx | 23 ++- .../StrategyTooltipLink.tsx | 36 +++-- .../component/events/EventDiff/EventDiff.tsx | 2 +- 8 files changed, 319 insertions(+), 143 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/LegacySegmentChangeDetails.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx new file mode 100644 index 0000000000..76807f37e0 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx @@ -0,0 +1,54 @@ +import { + Tab as MuiTab, + TabPanel as MuiTabPanel, + Tabs as MuiTabs, + TabsList as MuiTabsList, +} from '@mui/base'; +import { Button, type ButtonProps, styled } from '@mui/material'; +import type { PropsWithChildren } from 'react'; + +export const TabList = styled(MuiTabsList)(({ theme }) => ({ + display: 'inline-flex', + flexDirection: 'row', + gap: theme.spacing(0.5), +})); + +const StyledButton = styled(Button)(({ theme }) => ({ + whiteSpace: 'nowrap', + color: theme.palette.text.secondary, + fontWeight: 'normal', + '&[aria-selected="true"]': { + fontWeight: 'bold', + color: theme.palette.primary.main, + background: theme.palette.background.elevation1, + }, +})); + +export const Tab = styled(({ children }: ButtonProps) => ( + {children} +))(({ theme }) => ({ + position: 'absolute', + top: theme.spacing(-0.5), + left: theme.spacing(2), + transform: 'translateY(-50%)', + padding: theme.spacing(0.75, 1), + lineHeight: 1, + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.primary, + background: theme.palette.background.application, + borderRadius: theme.shape.borderRadiusExtraLarge, + zIndex: theme.zIndex.fab, + textTransform: 'uppercase', +})); + +export const Tabs = ({ children }: PropsWithChildren) => ( + + {children} + +); + +export const TabPanel = MuiTabPanel; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/LegacySegmentChangeDetails.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/LegacySegmentChangeDetails.tsx new file mode 100644 index 0000000000..ca516d38a8 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/LegacySegmentChangeDetails.tsx @@ -0,0 +1,129 @@ +import type React from 'react'; +import type { FC, ReactNode } from 'react'; +import { Box, styled, Typography } from '@mui/material'; +import type { + ChangeRequestState, + IChangeRequestDeleteSegment, + IChangeRequestUpdateSegment, +} from 'component/changeRequest/changeRequest.types'; +import { useSegment } from 'hooks/api/getters/useSegment/useSegment'; +import { SegmentDiff, SegmentTooltipLink } from '../../SegmentTooltipLink.tsx'; + +import { ViewableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/ViewableConstraintsList'; + +import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx'; + +const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'auto 40px', + gap: theme.spacing(1), + alignItems: 'center', + width: '100%', + margin: theme.spacing(0, 0, 1, 0), +})); + +export const ChangeItemWrapper = styled(Box)({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)( + ({ theme }) => ({ + display: 'grid', + gridTemplateColumns: '150px auto', + gridAutoFlow: 'column', + alignItems: 'center', + flexGrow: 1, + gap: theme.spacing(1), + }), +); + +const SegmentContainer = styled(Box, { + shouldForwardProp: (prop) => prop !== 'conflict', +})<{ conflict: string | undefined }>(({ theme, conflict }) => ({ + borderLeft: '1px solid', + borderRight: '1px solid', + borderTop: '1px solid', + borderBottom: '1px solid', + borderColor: conflict + ? theme.palette.warning.border + : theme.palette.divider, + borderTopColor: theme.palette.divider, + padding: theme.spacing(3), + borderRadius: `0 0 ${theme.shape.borderRadiusLarge}px ${theme.shape.borderRadiusLarge}px`, +})); + +/** + * Deprecated: use SegmentChangeDetails instead. Remove file with flag crDiffView + * @deprecated + */ +export const LegacySegmentChangeDetails: FC<{ + actions?: ReactNode; + change: IChangeRequestUpdateSegment | IChangeRequestDeleteSegment; + changeRequestState: ChangeRequestState; +}> = ({ actions, change, changeRequestState }) => { + const { segment: currentSegment } = useSegment(change.payload.id); + const snapshotSegment = change.payload.snapshot; + const previousName = + changeRequestState === 'Applied' + ? change.payload?.snapshot?.name + : currentSegment?.name; + const referenceSegment = + changeRequestState === 'Applied' ? snapshotSegment : currentSegment; + + return ( + + {change.action === 'deleteSegment' && ( + + + ({ + color: theme.palette.error.main, + })} + > + - Deleting segment: + + + + + +
{actions}
+
+ )} + {change.action === 'updateSegment' && ( + <> + + + + Editing segment: + + + + +
{actions}
+
+ + + )} +
+ ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx index ad3bb088b0..3a9e5517c4 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/SegmentChange.tsx @@ -5,9 +5,11 @@ import type { ChangeRequestState, ISegmentChange, } from '../../../changeRequest.types'; +import { LegacySegmentChangeDetails } from './LegacySegmentChangeDetails.tsx'; import { SegmentChangeDetails } from './SegmentChangeDetails.tsx'; import { ConflictWarning } from './ConflictWarning.tsx'; import { useSegment } from 'hooks/api/getters/useSegment/useSegment.ts'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; interface ISegmentChangeProps { segmentChange: ISegmentChange; @@ -24,6 +26,10 @@ export const SegmentChange: FC = ({ }) => { const { segment } = useSegment(segmentChange.payload.id); + const ChangeDetails = useUiFlag('crDiffView') + ? SegmentChangeDetails + : LegacySegmentChangeDetails; + return ( = ({ - ({ - display: 'grid', - gridTemplateColumns: 'auto 40px', + display: 'flex', gap: theme.spacing(1), alignItems: 'center', width: '100%', @@ -68,58 +67,89 @@ export const SegmentChangeDetails: FC<{ const referenceSegment = changeRequestState === 'Applied' ? snapshotSegment : currentSegment; + const actionsWithTabs = ( + <> + + Change + View diff + + {actions} + + ); + return ( - - {change.action === 'deleteSegment' && ( - - - ({ - color: theme.palette.error.main, - })} - > - - Deleting segment: - - + + + {change.action === 'deleteSegment' && ( + <> + + + ({ + color: theme.palette.error.main, + })} + > + - Deleting segment: + + + + + + {actionsWithTabs} + + + + - - -
{actions}
-
- )} - {change.action === 'updateSegment' && ( - <> - - - - Editing segment: - - - - -
{actions}
-
- - - )} -
+ + + )} + {change.action === 'updateSegment' && ( + <> + + + + Editing segment: + + + + + {actionsWithTabs} + + + + + + + + + + )} + + ); }; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx index 639e06672e..988ea09943 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx @@ -1,14 +1,6 @@ import type React from 'react'; import type { FC, ReactNode } from 'react'; -import { - Box, - Button, - type ButtonProps, - styled, - Tooltip, - Typography, -} from '@mui/material'; -import { Tab, Tabs, TabsList, TabPanel } from '@mui/base'; +import { Box, styled, Tooltip, Typography } from '@mui/material'; import BlockIcon from '@mui/icons-material/Block'; import TrackChangesIcon from '@mui/icons-material/TrackChanges'; import { @@ -29,6 +21,7 @@ import { flexRow } from 'themes/themeStyles'; import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable'; import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx'; import type { IFeatureStrategy } from 'interfaces/strategy'; +import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx'; export const ChangeItemWrapper = styled(Box)({ display: 'flex', @@ -41,6 +34,7 @@ const ChangeItemCreateEditDeleteWrapper = styled(Box)(({ theme }) => ({ justifyContent: 'space-between', gap: theme.spacing(1), alignItems: 'center', + marginBottom: theme.spacing(1), width: '100%', })); @@ -180,46 +174,6 @@ const DeleteStrategy: FC<{ ); }; -const ActionsContainer = styled('div')(({ theme }) => ({ - display: 'flex', - gap: theme.spacing(1), - alignItems: 'center', -})); - -const StyledTabList = styled(TabsList)(({ theme }) => ({ - display: 'inline-flex', - flexDirection: 'row', - gap: theme.spacing(0.5), -})); - -const StyledButton = styled(Button)(({ theme }) => ({ - whiteSpace: 'nowrap', - color: theme.palette.text.secondary, - fontWeight: 'normal', - '&[aria-selected="true"]': { - fontWeight: 'bold', - color: theme.palette.primary.main, - background: theme.palette.background.elevation1, - }, -})); - -export const StyledTab = styled(({ children }: ButtonProps) => ( - {children} -))(({ theme }) => ({ - position: 'absolute', - top: theme.spacing(-0.5), - left: theme.spacing(2), - transform: 'translateY(-50%)', - padding: theme.spacing(0.75, 1), - lineHeight: 1, - fontSize: theme.fontSizes.smallerBody, - color: theme.palette.text.primary, - background: theme.palette.background.application, - borderRadius: theme.shape.borderRadiusExtraLarge, - zIndex: theme.zIndex.fab, - textTransform: 'uppercase', -})); - const UpdateStrategy: FC<{ change: IChangeRequestUpdateStrategy; changeRequestState: ChangeRequestState; @@ -397,31 +351,27 @@ export const StrategyChange: FC<{ environmentName, ); - const Actions = ( - - - Change - View diff - + const actionsWithTabs = ( + <> + + Change + View diff + {actions} - + ); return ( - + {change.action === 'addStrategy' && ( - + )} {change.action === 'deleteStrategy' && ( )} {change.action === 'updateStrategy' && ( @@ -429,7 +379,7 @@ export const StrategyChange: FC<{ change={change} changeRequestState={changeRequestState} currentStrategy={currentStrategy} - actions={Actions} + actions={actionsWithTabs} /> )} diff --git a/frontend/src/component/changeRequest/ChangeRequest/SegmentTooltipLink.tsx b/frontend/src/component/changeRequest/ChangeRequest/SegmentTooltipLink.tsx index 30ee1926b1..50670c1333 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/SegmentTooltipLink.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/SegmentTooltipLink.tsx @@ -3,14 +3,15 @@ import type { IChangeRequestUpdateSegment, } from 'component/changeRequest/changeRequest.types'; import type React from 'react'; -import type { FC } from 'react'; -import EventDiff from 'component/events/EventDiff/EventDiff'; +import { Fragment, type FC } from 'react'; import omit from 'lodash.omit'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import { styled } from '@mui/material'; import { textTruncated } from 'themes/themeStyles'; import type { ISegment } from 'interfaces/segment'; import { NameWithChangeInfo } from './NameWithChangeInfo/NameWithChangeInfo.tsx'; +import { EventDiff } from 'component/events/EventDiff/EventDiff.tsx'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; const StyledCodeSection = styled('div')(({ theme }) => ({ overflowX: 'auto', @@ -23,22 +24,32 @@ const StyledCodeSection = styled('div')(({ theme }) => ({ }, })); +const omitIfDefined = (obj: any, keys: string[]) => + obj ? omit(obj, keys) : obj; + export const SegmentDiff: FC<{ change: IChangeRequestUpdateSegment | IChangeRequestDeleteSegment; currentSegment?: ISegment; }> = ({ change, currentSegment }) => { + const useNewDiff = useUiFlag('improvedJsonDiff'); + const Wrapper = useNewDiff ? Fragment : StyledCodeSection; + const omissionFunction = useNewDiff ? omitIfDefined : omit; + const changeRequestSegment = change.action === 'deleteSegment' ? undefined : change.payload; return ( - + - + ); }; interface IStrategyTooltipLinkProps { diff --git a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx index d6f7dd15c1..f3d5b4248f 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx @@ -4,12 +4,12 @@ import type { IChangeRequestUpdateStrategy, } from 'component/changeRequest/changeRequest.types'; import type React from 'react'; -import type { FC } from 'react'; +import { Fragment, type FC } from 'react'; import { formatStrategyName, GetFeatureStrategyIcon, } from 'utils/strategyNames'; -import EventDiff, { NewEventDiff } from 'component/events/EventDiff/EventDiff'; +import { EventDiff } from 'component/events/EventDiff/EventDiff'; import omit from 'lodash.omit'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import { Typography, styled } from '@mui/material'; @@ -41,6 +41,9 @@ const sortSegments = ( }; }; +const omitIfDefined = (obj: any, keys: string[]) => + obj ? omit(obj, keys) : obj; + export const StrategyDiff: FC<{ change: | IChangeRequestAddStrategy @@ -54,29 +57,22 @@ export const StrategyDiff: FC<{ const sortedCurrentStrategy = sortSegments(currentStrategy); const sortedChangeRequestStrategy = sortSegments(changeRequestStrategy); - if (useNewDiff) { - return ( - - ); - } + + const Wrapper = useNewDiff ? Fragment : StyledCodeSection; + const omissionFunction = useNewDiff ? omitIfDefined : omit; return ( - + - + ); }; diff --git a/frontend/src/component/events/EventDiff/EventDiff.tsx b/frontend/src/component/events/EventDiff/EventDiff.tsx index 93968bc36f..0e43014421 100644 --- a/frontend/src/component/events/EventDiff/EventDiff.tsx +++ b/frontend/src/component/events/EventDiff/EventDiff.tsx @@ -212,7 +212,7 @@ const OldEventDiff: FC = ({ ); }; -const EventDiff: FC = (props) => { +export const EventDiff: FC = (props) => { const useNewJsonDiff = useUiFlag('improvedJsonDiff'); if (useNewJsonDiff) { return ; From 873d64e84bc220321771518c027077f4a12ee1e7 Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Mon, 30 Jun 2025 11:54:55 +0200 Subject: [PATCH 08/20] task: Added name and email of requester to track down spammers (#10235) As the title says. This adds name and email of requester to CR approval mails. This will hopefully have users complain to their coworkers rather than Unleash if they get too many mails. --- src/lib/services/email-service.ts | 17 ++++++++++------- .../requested-cr-approval.html.mustache | 2 +- .../requested-cr-approval.plain.mustache | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lib/services/email-service.ts b/src/lib/services/email-service.ts index 9c2f2b0259..f3776ea687 100644 --- a/src/lib/services/email-service.ts +++ b/src/lib/services/email-service.ts @@ -45,6 +45,13 @@ export interface IEmailEnvelope { headers?: Record; } +export interface ICrApprovalParameters { + changeRequestLink: string; + changeRequestTitle: string; + requesterName: string; + requesterEmail: string; +} + const RESET_MAIL_SUBJECT = 'Unleash - Reset your password'; const GETTING_STARTED_SUBJECT = 'Welcome to Unleash'; const PRODUCTIVITY_REPORT = 'Unleash - productivity report'; @@ -124,8 +131,7 @@ export class EmailService { async sendRequestedCRApprovalEmail( recipient: string, - changeRequestLink: string, - changeRequestTitle: string, + crApprovalParams: ICrApprovalParameters, ): Promise { if (this.configured()) { const year = new Date().getFullYear(); @@ -133,8 +139,7 @@ export class EmailService { 'requested-cr-approval', TemplateFormat.HTML, { - changeRequestLink, - changeRequestTitle, + ...crApprovalParams, year, }, ); @@ -142,8 +147,7 @@ export class EmailService { 'requested-cr-approval', TemplateFormat.PLAIN, { - changeRequestLink, - changeRequestTitle, + ...crApprovalParams, year, }, ); @@ -173,7 +177,6 @@ export class EmailService { this.logger.warn( 'No mailer is configured. Please read the docs on how to configure an email service', ); - this.logger.debug('Change request link: ', changeRequestLink); res({ from: this.sender, to: recipient, diff --git a/src/mailtemplates/requested-cr-approval/requested-cr-approval.html.mustache b/src/mailtemplates/requested-cr-approval/requested-cr-approval.html.mustache index 222f25a351..6f51cde264 100644 --- a/src/mailtemplates/requested-cr-approval/requested-cr-approval.html.mustache +++ b/src/mailtemplates/requested-cr-approval/requested-cr-approval.html.mustache @@ -340,7 +340,7 @@ diff --git a/src/mailtemplates/requested-cr-approval/requested-cr-approval.plain.mustache b/src/mailtemplates/requested-cr-approval/requested-cr-approval.plain.mustache index 6e74deb6ad..7f6ca0b7f7 100644 --- a/src/mailtemplates/requested-cr-approval/requested-cr-approval.plain.mustache +++ b/src/mailtemplates/requested-cr-approval/requested-cr-approval.plain.mustache @@ -1,3 +1,3 @@ -You have been added to review {{{ changeRequestTitle }}} +You have been added to review {{{ changeRequestTitle }}} by {{{ requesterName }}} ({{{ requesterEmail }}}) Follow the link: {{{ changeRequestLink }}} to review it. From fc51bb28be2b5ca6c67c58c9e4c6dc04c5796d79 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:59:29 +0000 Subject: [PATCH 09/20] fix(deps): update dependency @slack/web-api to v7.9.3 (#10238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [@slack/web-api](https://tools.slack.dev/node-slack-sdk/web-api) ([source](https://redirect.github.com/slackapi/node-slack-sdk)) | [`7.9.1` -> `7.9.3`](https://renovatebot.com/diffs/npm/@slack%2fweb-api/7.9.1/7.9.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@slack%2fweb-api/7.9.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@slack%2fweb-api/7.9.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@slack%2fweb-api/7.9.1/7.9.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@slack%2fweb-api/7.9.1/7.9.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
slackapi/node-slack-sdk (@​slack/web-api) ### [`v7.9.3`](https://redirect.github.com/slackapi/node-slack-sdk/releases/tag/%40slack/web-api%407.9.3) [Compare Source](https://redirect.github.com/slackapi/node-slack-sdk/compare/@slack/web-api@7.9.2...@slack/web-api@7.9.3) #### What's Changed This release has a few small updates to align with arguments of various Slack API methods. ##### πŸ‘Ύ Enhancements - feat(web-api): include a blocks argument for file uploads in [https://github.com/slackapi/node-slack-sdk/pull/2261](https://redirect.github.com/slackapi/node-slack-sdk/pull/2261) - Thanks [@​zimeg](https://redirect.github.com/zimeg)! ##### πŸ› Fixes - fix: Add "title" property to conversations.canvases.create API method arguments in [https://github.com/slackapi/node-slack-sdk/pull/2259](https://redirect.github.com/slackapi/node-slack-sdk/pull/2259) - Thanks [@​vegeris](https://redirect.github.com/vegeris)! ##### 🧰 Maintenance - chore(deps-dev): bump sinon from 20.0.0 to 21.0.0 in /packages/web-api in the dev-sinon group across 1 directory in [https://github.com/slackapi/node-slack-sdk/pull/2269](https://redirect.github.com/slackapi/node-slack-sdk/pull/2269) - Thanks [@​dependabot](https://redirect.github.com/dependabot)! - chore(web-api): release [@​slack/web-api](https://redirect.github.com/slack/web-api)@​7.9[https://github.com/slackapi/node-slack-sdk/pull/2272](https://redirect.github.com/slackapi/node-slack-sdk/pull/2272)l/2272 - Thanks [@​zimeg](https://redirect.github.com/zimeg)! **Full Changelog**: https://github.com/slackapi/node-slack-sdk/compare/[@​slack/web-api](https://redirect.github.com/slack/web-api)@​7.9.2...[@​slack/web-api](https://redirect.github.com/slack/web-api)@​7.9.3 **Milestone**: https://github.com/slackapi/node-slack-sdk/milestone/143 ### [`v7.9.2`](https://redirect.github.com/slackapi/node-slack-sdk/releases/tag/%40slack/web-api%407.9.2) [Compare Source](https://redirect.github.com/slackapi/node-slack-sdk/compare/@slack/web-api@7.9.1...@slack/web-api@7.9.2) #### What's Changed ##### πŸ› Fixes - fix: add trailing slash to slackApiUrl if none is provided by [@​WilliamBergamin](https://redirect.github.com/WilliamBergamin) in [https://github.com/slackapi/node-slack-sdk/pull/2243](https://redirect.github.com/slackapi/node-slack-sdk/pull/2243) ##### πŸ“– Docs - docs: moves guides from wiki to docs website by [@​lukegalbraithrussell](https://redirect.github.com/lukegalbraithrussell) in [https://github.com/slackapi/node-slack-sdk/pull/2238](https://redirect.github.com/slackapi/node-slack-sdk/pull/2238) ##### 🧰 Maintenance - chore(deps-dev): bump tsd from 0.31.2 to 0.32.0 in /packages/web-api by [@​dependabot](https://redirect.github.com/dependabot) in [https://github.com/slackapi/node-slack-sdk/pull/2236](https://redirect.github.com/slackapi/node-slack-sdk/pull/2236) - chore(deps-dev): bump typescript from 5.3.3 to 5.8.3 in /packages/web-api by [@​dependabot](https://redirect.github.com/dependabot) in [https://github.com/slackapi/node-slack-sdk/pull/2222](https://redirect.github.com/slackapi/node-slack-sdk/pull/2222) - chore(deps-dev): bump nock from 13.5.6 to 14.0.3 in /packages/web-api by [@​dependabot](https://redirect.github.com/dependabot) in [https://github.com/slackapi/node-slack-sdk/pull/2223](https://redirect.github.com/slackapi/node-slack-sdk/pull/2223) - chore(web-api): release [@​slack/web-api](https://redirect.github.com/slack/web-api)@​7.9.2 by [@​WilliamBergamin](https://redirect.github.com/WilliamBergamin) in [https://github.com/slackapi/node-slack-sdk/pull/2247](https://redirect.github.com/slackapi/node-slack-sdk/pull/2247) **Full Changelog**: https://github.com/slackapi/node-slack-sdk/compare/[@​slack/socket-mode](https://redirect.github.com/slack/socket-mode)@​2.0.4...[@​slack/web-api](https://redirect.github.com/slack/web-api)@​7.9.2 **Milstone**: https://github.com/slackapi/node-slack-sdk/milestone/141?closed=1
--- ### Configuration πŸ“… **Schedule**: Branch creation - "after 7pm every weekday,before 5am every weekday" in timezone Europe/Madrid, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/Unleash/unleash). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0ec137937a..9ad175e8d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1167,8 +1167,8 @@ __metadata: linkType: hard "@slack/web-api@npm:^7.9.1": - version: 7.9.1 - resolution: "@slack/web-api@npm:7.9.1" + version: 7.9.3 + resolution: "@slack/web-api@npm:7.9.3" dependencies: "@slack/logger": "npm:^4.0.0" "@slack/types": "npm:^2.9.0" @@ -1182,7 +1182,7 @@ __metadata: p-queue: "npm:^6" p-retry: "npm:^4" retry: "npm:^0.13.1" - checksum: 10c0/76d5d935518f3c2ab9eea720c0736f5722ac6c3244f4b1ba29aa3f3525803ecc00662209c8a1cce2c2a94ec71d607c05f5f6a24456dd3e60fa65b3fe0c7b9820 + checksum: 10c0/a08342156683abe6cd05659c6a51eeb782adcd7f418df43ad212501f0d0da77c6caf57785491a7f024116499e1819da589b803622318454b8abe6b375ba1d8c0 languageName: node linkType: hard From 207a0d3a4c3ba2dca7daa2664e77acf55b9e5bd4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:00:12 +0000 Subject: [PATCH 10/20] fix(deps): update dependency pg-connection-string to v2.9.1 (#10239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [pg-connection-string](https://redirect.github.com/brianc/node-postgres/tree/master/packages/pg-connection-string) ([source](https://redirect.github.com/brianc/node-postgres/tree/HEAD/packages/pg-connection-string)) | [`2.9.0` -> `2.9.1`](https://renovatebot.com/diffs/npm/pg-connection-string/2.9.0/2.9.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/pg-connection-string/2.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/pg-connection-string/2.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/pg-connection-string/2.9.0/2.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pg-connection-string/2.9.0/2.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
brianc/node-postgres (pg-connection-string) ### [`v2.9.1`](https://redirect.github.com/brianc/node-postgres/compare/pg-connection-string@2.9.0...pg-connection-string@2.9.1) [Compare Source](https://redirect.github.com/brianc/node-postgres/compare/pg-connection-string@2.9.0...pg-connection-string@2.9.1)
--- ### Configuration πŸ“… **Schedule**: Branch creation - "after 7pm every weekday,before 5am every weekday" in timezone Europe/Madrid, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/Unleash/unleash). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9ad175e8d0..cdad3e7261 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5984,9 +5984,9 @@ __metadata: linkType: hard "pg-connection-string@npm:^2.5.0, pg-connection-string@npm:^2.9.0": - version: 2.9.0 - resolution: "pg-connection-string@npm:2.9.0" - checksum: 10c0/7145d00688200685a9d9931a7fc8d61c75f348608626aef88080ece956ceb4ff1cbdee29c3284e41b7a3345bab0e4f50f9edc256e270bfa3a563af4ea78bb490 + version: 2.9.1 + resolution: "pg-connection-string@npm:2.9.1" + checksum: 10c0/9a646529bbc0843806fc5de98ce93735a4612b571f11867178a85665d11989a827e6fd157388ca0e34ec948098564fce836c178cfd499b9f0e8cd9972b8e2e5c languageName: node linkType: hard From fdc79e624fb7582b412c8bb6ead3e59ef3fb04af Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:00:49 +0000 Subject: [PATCH 11/20] fix(deps): update dependency git-url-parse to v16.1.0 (#10240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [git-url-parse](https://redirect.github.com/IonicaBizau/git-url-parse) | [`16.0.1` -> `16.1.0`](https://renovatebot.com/diffs/npm/git-url-parse/16.0.1/16.1.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/git-url-parse/16.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/git-url-parse/16.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/git-url-parse/16.0.1/16.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/git-url-parse/16.0.1/16.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
IonicaBizau/git-url-parse (git-url-parse) ### [`v16.1.0`](https://redirect.github.com/IonicaBizau/git-url-parse/releases/tag/16.1.0) [Compare Source](https://redirect.github.com/IonicaBizau/git-url-parse/compare/16.0.3...16.1.0) 16.1.0 ### [`v16.0.3`](https://redirect.github.com/IonicaBizau/git-url-parse/releases/tag/16.0.3) [Compare Source](https://redirect.github.com/IonicaBizau/git-url-parse/compare/16.0.2...16.0.3) 16.0.3 ### [`v16.0.2`](https://redirect.github.com/IonicaBizau/git-url-parse/releases/tag/16.0.2) [Compare Source](https://redirect.github.com/IonicaBizau/git-url-parse/compare/16.0.1...16.0.2) :memo: Docs
--- ### Configuration πŸ“… **Schedule**: Branch creation - "after 7pm every weekday,before 5am every weekday" in timezone Europe/Madrid, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. β™» **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/Unleash/unleash). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- website/yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index ffafa4d226..0ce051fe39 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -8412,22 +8412,22 @@ __metadata: languageName: node linkType: hard -"git-up@npm:^8.0.0": - version: 8.0.1 - resolution: "git-up@npm:8.0.1" +"git-up@npm:^8.1.0": + version: 8.1.1 + resolution: "git-up@npm:8.1.1" dependencies: is-ssh: "npm:^1.4.0" parse-url: "npm:^9.2.0" - checksum: 10c0/9aa809907ecfc96093d91e2fc68644ace1ac184ed613a67d74f24627172f62f73cc0149037975bd2edf6540676db99632692dc3b18e0a053273e160cf158973f + checksum: 10c0/2cc4461d8565a3f7a1ecd3d262a58ddb8df0a67f7f7d4915df2913c460b2e88ae570a6ea810700a6d22fb3b9e4bea8dd10a8eb469900ddc12e35c62208608c03 languageName: node linkType: hard "git-url-parse@npm:^16.0.0": - version: 16.0.1 - resolution: "git-url-parse@npm:16.0.1" + version: 16.1.0 + resolution: "git-url-parse@npm:16.1.0" dependencies: - git-up: "npm:^8.0.0" - checksum: 10c0/bef681b3726c730a3efb599d38ab6affbb13e5e85269fc9c35831ddfe0d195e6a29098c79c8faa63ccd8503ace54c2c4b01a73122af3b66c2ce11f4692b3ef19 + git-up: "npm:^8.1.0" + checksum: 10c0/b8f5ebcbd5b2baf9f1bb77a217376f0247c47fe1d42811ccaac3015768eebb0759a59051f758e50e70adf5c67ae059d1975bf6b750164f36bfd39138d11b940b languageName: node linkType: hard From 7c0bd12a249745baeaaa74e671d54324cfc13f24 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 13:18:18 +0200 Subject: [PATCH 12/20] add tabs to milestone start (#10237) Adds changes/view diff tabs to release plan changes that show diffs. The only instances I found where we show JSON diffs today was starting milestones and adding a new release plan if you already have one. I've moved the old file into a legacy file because we're touching two out of three internal components, so it seemed like leaving it all in one file would be a bit of a hassle. plus, this way it's consistent with segments and strategies. Start milestone: image image Plan replacement: image image --- .../Changes/Change/FeatureChange.tsx | 7 +- .../Change/LegacyReleasePlanChange.tsx | 316 ++++++++++++++++++ .../Changes/Change/ReleasePlanChange.tsx | 158 ++++----- 3 files changed, 402 insertions(+), 79 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/LegacyReleasePlanChange.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index d3c90457a3..742374bfbb 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -14,6 +14,7 @@ import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutio import { ArchiveFeatureChange } from './ArchiveFeatureChange.tsx'; import { DependencyChange } from './DependencyChange.tsx'; import { Link } from 'react-router-dom'; +import { LegacyReleasePlanChange } from './LegacyReleasePlanChange.tsx'; import { ReleasePlanChange } from './ReleasePlanChange.tsx'; import { StrategyChange } from './StrategyChange.tsx'; import { useUiFlag } from 'hooks/useUiFlag.ts'; @@ -94,6 +95,10 @@ export const FeatureChange: FC<{ ? StrategyChange : LegacyStrategyChange; + const ReleasePlanChangeComponent = useDiffableChangeComponent + ? ReleasePlanChange + : LegacyReleasePlanChange; + return ( ({ + display: 'grid', + gridTemplateColumns: 'auto auto', + justifyContent: 'space-between', + gap: theme.spacing(1), + alignItems: 'center', + marginBottom: theme.spacing(2), + width: '100%', +})); + +const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)( + ({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + }), +); + +const ViewDiff = styled('span')(({ theme }) => ({ + color: theme.palette.primary.main, + marginLeft: theme.spacing(1), +})); + +const StyledCodeSection = styled('div')(({ theme }) => ({ + overflowX: 'auto', + '& code': { + wordWrap: 'break-word', + whiteSpace: 'pre-wrap', + fontFamily: 'monospace', + lineHeight: 1.5, + fontSize: theme.fontSizes.smallBody, + }, +})); + +const DeleteReleasePlan: FC<{ + change: IChangeRequestDeleteReleasePlan; + currentReleasePlan?: IReleasePlan; + changeRequestState: ChangeRequestState; + actions?: ReactNode; +}> = ({ change, currentReleasePlan, changeRequestState, actions }) => { + const releasePlan = + changeRequestState === 'Applied' && change.payload.snapshot + ? change.payload.snapshot + : currentReleasePlan; + + if (!releasePlan) return; + + return ( + <> + + + ({ + color: theme.palette.error.main, + })} + > + - Deleting release plan: + + {releasePlan.name} + +
{actions}
+
+ + + ); +}; + +const StartMilestone: FC<{ + change: IChangeRequestStartMilestone; + currentReleasePlan?: IReleasePlan; + changeRequestState: ChangeRequestState; + actions?: ReactNode; +}> = ({ change, currentReleasePlan, changeRequestState, actions }) => { + const releasePlan = + changeRequestState === 'Applied' && change.payload.snapshot + ? change.payload.snapshot + : currentReleasePlan; + + if (!releasePlan) return; + + const previousMilestone = releasePlan.milestones.find( + (milestone) => milestone.id === releasePlan.activeMilestoneId, + ); + + const newMilestone = releasePlan.milestones.find( + (milestone) => milestone.id === change.payload.milestoneId, + ); + + if (!newMilestone) return; + + return ( + <> + + + + + Start milestone: + + {newMilestone.name} + + + + } + tooltipProps={{ + maxWidth: 500, + maxHeight: 600, + }} + > + View Diff + + +
{actions}
+
+ + + ); +}; + +const AddReleasePlan: FC<{ + change: IChangeRequestAddReleasePlan; + currentReleasePlan?: IReleasePlan; + environmentName: string; + featureName: string; + actions?: ReactNode; +}> = ({ + change, + currentReleasePlan, + environmentName, + featureName, + actions, +}) => { + const [currentTooltipOpen, setCurrentTooltipOpen] = useState(false); + const currentTooltipCloseTimeoutRef = useRef(); + const openCurrentTooltip = () => { + if (currentTooltipCloseTimeoutRef.current) { + clearTimeout(currentTooltipCloseTimeoutRef.current); + } + setCurrentTooltipOpen(true); + }; + const closeCurrentTooltip = () => { + currentTooltipCloseTimeoutRef.current = setTimeout(() => { + setCurrentTooltipOpen(false); + }, 100); + }; + + const planPreview = useReleasePlanPreview( + change.payload.templateId, + featureName, + environmentName, + ); + + const planPreviewDiff = { + ...planPreview, + discriminator: 'plan', + releasePlanTemplateId: change.payload.templateId, + }; + + return ( + <> + + + {currentReleasePlan ? ( + + Replacing{' '} + + openCurrentTooltip() + } + onMouseLeave={() => + closeCurrentTooltip() + } + > + + + } + tooltipProps={{ + open: currentTooltipOpen, + maxWidth: 500, + maxHeight: 600, + }} + > + openCurrentTooltip()} + onMouseLeave={() => closeCurrentTooltip()} + > + current + + {' '} + release plan with: + + ) : ( + + + Adding release plan: + + )} + {planPreview.name} + {currentReleasePlan && ( + + + + } + tooltipProps={{ + maxWidth: 500, + maxHeight: 600, + }} + > + View Diff + + )} + +
{actions}
+
+ + + ); +}; + +/** + * Deprecated: use ReleasePlanChange instead. Remove file with flag crDiffView + * @deprecated + */ +export const LegacyReleasePlanChange: FC<{ + actions?: ReactNode; + change: + | IChangeRequestAddReleasePlan + | IChangeRequestDeleteReleasePlan + | IChangeRequestStartMilestone; + environmentName: string; + featureName: string; + projectId: string; + changeRequestState: ChangeRequestState; +}> = ({ + actions, + change, + featureName, + environmentName, + projectId, + changeRequestState, +}) => { + const { releasePlans } = useReleasePlans( + projectId, + featureName, + environmentName, + ); + const currentReleasePlan = releasePlans[0]; + + return ( + <> + {change.action === 'addReleasePlan' && ( + + )} + {change.action === 'deleteReleasePlan' && ( + + )} + {change.action === 'startMilestone' && ( + + )} + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx index 7511884a7d..7cabd17f99 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx @@ -10,10 +10,11 @@ import type { import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview'; import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; -import EventDiff from 'component/events/EventDiff/EventDiff'; +import { EventDiff } from 'component/events/EventDiff/EventDiff'; import { ReleasePlan } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan'; import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone'; import type { IReleasePlan } from 'interfaces/releasePlans'; +import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx'; export const ChangeItemWrapper = styled(Box)({ display: 'flex', @@ -111,36 +112,34 @@ const StartMilestone: FC<{ if (!newMilestone) return; return ( - <> + + Start milestone: {newMilestone.name} - - - - } - tooltipProps={{ - maxWidth: 500, - maxHeight: 600, - }} - > - View Diff - -
{actions}
+
+ + Change + View diff + + {actions} +
- - + + + + + + +
); }; @@ -183,75 +182,78 @@ const AddReleasePlan: FC<{ releasePlanTemplateId: change.payload.templateId, }; - return ( - <> - - - {currentReleasePlan ? ( - - Replacing{' '} - - openCurrentTooltip() - } - onMouseLeave={() => - closeCurrentTooltip() - } - > - - - } - tooltipProps={{ - open: currentTooltipOpen, - maxWidth: 500, - maxHeight: 600, - }} - > - openCurrentTooltip()} - onMouseLeave={() => closeCurrentTooltip()} - > - current - - {' '} - release plan with: - - ) : ( + if (!currentReleasePlan) { + return ( + <> + + + Adding release plan: - )} - {planPreview.name} - {currentReleasePlan && ( + {planPreview.name} + +
{actions}
+
+ + + ); + } + + return ( + + + + + Replacing{' '} - openCurrentTooltip()} + onMouseLeave={() => closeCurrentTooltip()} + > + - + } tooltipProps={{ + open: currentTooltipOpen, maxWidth: 500, maxHeight: 600, }} > - View Diff - - )} + openCurrentTooltip()} + onMouseLeave={() => closeCurrentTooltip()} + > + current + + {' '} + release plan with: + + {planPreview.name} -
{actions}
+
+ + Changes + View diff + + {actions} +
- - + + + + + + +
); }; From f18509665dc12af4603f20e1047bc5f5d16ee928 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 13:40:28 +0200 Subject: [PATCH 13/20] add tabs to strategy sort order (#10243) Adds the same tab interface to env execution order changes as to other diffable changes. Instead of creating a new file, this one just duplicates and changes the component that we wanna touch. Change image Diff image --- .../EnvironmentStrategyExecutionOrder.tsx | 40 +++++++++++++++++++ .../EnvironmentStrategyOrderDiff.tsx | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx index 61315cb836..9dd554f918 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx @@ -7,6 +7,8 @@ import { EnvironmentStrategyOrderDiff } from './EnvironmentStrategyOrderDiff.tsx import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution'; import { formatStrategyName } from '../../../../../../utils/strategyNames.tsx'; import type { IFeatureStrategy } from 'interfaces/strategy.ts'; +import { Tab, TabList, TabPanel, Tabs } from '../ChangeTabComponents.tsx'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; const ChangeItemInfo = styled(Box)({ display: 'flex', @@ -52,6 +54,7 @@ export const EnvironmentStrategyExecutionOrder = ({ actions, }: IEnvironmentStrategyExecutionOrderProps) => { const { feature: featureData, loading } = useFeature(project, feature); + const useDiffableComponent = useUiFlag('crDiffView'); if (loading) return null; @@ -85,6 +88,43 @@ export const EnvironmentStrategyExecutionOrder = ({ strategyIds: updatedStrategies.map((strategy) => strategy.id), }; + if (useDiffableComponent) { + return ( + + + +

Updating strategy execution order to:

+
+ + Change + View diff + + {actions} +
+
+ + + {updatedStrategies.map((strategy, index) => ( + + {`${index + 1}: `} + {formatStrategyName(strategy?.name || '')} + {strategy?.title && ` - ${strategy.title}`} + + + ))} + + + + + +
+
+ ); + } + return ( diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyOrderDiff.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyOrderDiff.tsx index 834dfc8489..2ad4a23b2a 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyOrderDiff.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyOrderDiff.tsx @@ -1,5 +1,5 @@ import { styled } from '@mui/material'; -import EventDiff from 'component/events/EventDiff/EventDiff'; +import { EventDiff } from 'component/events/EventDiff/EventDiff'; const StyledCodeSection = styled('div')(({ theme }) => ({ overflowX: 'auto', From 88514077f512c29d00d01c95a95cba9bf64740a1 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 14:17:49 +0200 Subject: [PATCH 14/20] Chore(1-3882)/add diff border (#10244) Adds a border around all the JSON diffs panels to align better with the sketches and to mesh better with the existing change cards in CRs. image --- .../Changes/Change/ChangeTabComponents.tsx | 12 +++++++- .../EnvironmentStrategyExecutionOrder.tsx | 2 +- .../EnvironmentStrategyOrderDiff.tsx | 29 ++++++++++++------- .../Changes/Change/ReleasePlanChange.tsx | 4 +-- .../Changes/Change/SegmentChangeDetails.tsx | 4 +-- .../Changes/Change/StrategyChange.tsx | 6 ++-- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx index 76807f37e0..b79d41b177 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeTabComponents.tsx @@ -51,4 +51,14 @@ export const Tabs = ({ children }: PropsWithChildren) => ( ); -export const TabPanel = MuiTabPanel; +export const TabPanel = styled(MuiTabPanel, { + shouldForwardProp: (prop) => prop !== 'variant', +})<{ variant?: 'diff' | 'change' }>(({ theme, variant }) => + variant === 'diff' + ? { + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadiusLarge, + border: `1px solid ${theme.palette.divider}`, + } + : {}, +); diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx index 9dd554f918..63e79fe0ce 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder.tsx @@ -114,7 +114,7 @@ export const EnvironmentStrategyExecutionOrder = ({ ))} - + ({ overflowX: 'auto', @@ -17,14 +19,19 @@ interface IDiffProps { data: StrategyIds; } -export const EnvironmentStrategyOrderDiff = ({ preData, data }: IDiffProps) => ( - - a.index - b.index} - /> - -); +export const EnvironmentStrategyOrderDiff = ({ preData, data }: IDiffProps) => { + const useNewDiff = useUiFlag('improvedJsonDiff'); + const Wrapper = useNewDiff ? Fragment : StyledCodeSection; + + return ( + + a.index - b.index} + /> + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx index 7cabd17f99..ad5a7ffde5 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx @@ -131,7 +131,7 @@ const StartMilestone: FC<{ - + - + - + - + )} - + ) : null} - + ) : null} - + From d7c465fd20a28f786b815df4edf78bead5e6aa92 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 14:44:51 +0200 Subject: [PATCH 15/20] chore: use `pre-wrap` for whitespace to break when necessary. (#10245) Use `white-space: pre-wrap` on event diff lines instead of just `pre`. This prevents us from getting a horizontal overflow and will instead wrap the lines if it needs to, but preserve indentation and other spaces (as explained in [MDN's white-space docs](https://developer.mozilla.org/en-US/docs/Web/CSS/white-space)). Means that instead of getting a horizontal overflow and a scroll bar, we get something like this instead: ![image](https://github.com/user-attachments/assets/d2fab200-6f14-47bc-8d4a-bcbb424fa762) --- frontend/src/component/events/EventDiff/EventDiff.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/component/events/EventDiff/EventDiff.tsx b/frontend/src/component/events/EventDiff/EventDiff.tsx index 0e43014421..3e4c75bdf2 100644 --- a/frontend/src/component/events/EventDiff/EventDiff.tsx +++ b/frontend/src/component/events/EventDiff/EventDiff.tsx @@ -29,7 +29,7 @@ interface IEventDiffProps { const DiffStyles = styled('div')(({ theme }) => ({ color: theme.palette.text.secondary, fontFamily: 'monospace', - whiteSpace: 'pre', + whiteSpace: 'pre-wrap', fontSize: theme.typography.body2.fontSize, '.deletion, .addition': { From 0ee1c90fa29f3c0e13148dbfa754685d9e46f766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 30 Jun 2025 13:54:37 +0100 Subject: [PATCH 16/20] chore: AI flag cleanup action test (#10242) Adds a test AI flag cleanup action. --- .github/workflows/ai-flag-cleanup.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/ai-flag-cleanup.yml diff --git a/.github/workflows/ai-flag-cleanup.yml b/.github/workflows/ai-flag-cleanup.yml new file mode 100644 index 0000000000..ede77b7b76 --- /dev/null +++ b/.github/workflows/ai-flag-cleanup.yml @@ -0,0 +1,17 @@ +name: AI flag cleanup +on: + issues: + types: [labeled] + +jobs: + flag-cleanup: + uses: mirrajabi/aider-github-workflows/.github/workflows/aider-issue-to-pr.yml@v1.0.0 + if: github.event.label.name == 'unleash-flag-completed' + with: + issue-number: ${{ github.event.issue.number }} + base-branch: ${{ github.event.repository.default_branch }} + chat-timeout: 10 + api_key_env_name: GEMINI_API_KEY + model: gemini + secrets: + api_key_env_value: ${{ secrets.GEMINI_API_KEY }} From f6ab7460c698e8fbb9e44743b9df54c1034aa10b Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Jun 2025 14:56:15 +0200 Subject: [PATCH 17/20] Remove the red coloration of deleted strategy change boxes (#10246) We don't want deleted strategies to have the red background color anymore with the new change/diff view. As such, we can remove it in the new component and add a todo to delete the css for it after it's gone. image --- .../ChangeRequest/Changes/Change/FeatureChange.tsx | 1 + .../ChangeRequest/Changes/Change/StrategyChange.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index 742374bfbb..31e023e9e5 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -73,6 +73,7 @@ const InlineList = styled('ul')(({ theme }) => ({ const ChangeInnerBox = styled(Box)(({ theme }) => ({ padding: theme.spacing(3), + // todo: remove with flag crDiffView '&:has(.delete-strategy-information-wrapper)': { backgroundColor: theme.palette.error.light, }, diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx index 3dcab834e4..d5cc72eea3 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/StrategyChange.tsx @@ -141,7 +141,7 @@ const DeleteStrategy: FC<{ return ( <> - + ({ From 681ce3bfd9d5a4dbdd82d38bda1bffc481461075 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 30 Jun 2025 16:00:57 +0300 Subject: [PATCH 18/20] feat: start storing every transaction id in events table (#10236) Every time an event gets inserted, we check if there is transaction data, that we can include. --- src/lib/events/index.ts | 2 + src/lib/features/events/event-store.ts | 14 +- src/lib/openapi/spec/event-schema.ts | 12 ++ src/test/e2e/stores/event-store.e2e.test.ts | 157 ++++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/lib/events/index.ts b/src/lib/events/index.ts index 5f1c95dfbf..cbc576a5c1 100644 --- a/src/lib/events/index.ts +++ b/src/lib/events/index.ts @@ -397,6 +397,8 @@ export interface IEvent extends Omit { id: number; createdAt: Date; ip?: string; + groupType?: string; + groupId?: string; } export interface IEnrichedEvent extends IEvent { diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index 0b08abf52e..8831bb5830 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -41,6 +41,8 @@ const EVENT_COLUMNS = [ 'feature_name', 'project', 'environment', + 'group_type', + 'group_id', ] as const; export type IQueryOperations = @@ -97,6 +99,8 @@ export interface IEventTable { environment?: string; tags: ITag[]; ip?: string; + group_type: string | null; + group_id: string | null; } const TABLE = 'events'; @@ -157,7 +161,9 @@ export class EventStore implements IEventStore { async batchStore(events: IBaseEvent[]): Promise { try { - await this.db(TABLE).insert(events.map(this.eventToDbRow)); + await this.db(TABLE).insert( + events.map((event) => this.eventToDbRow(event)), + ); } catch (error: unknown) { this.logger.warn( `Failed to store events: ${JSON.stringify(events)}`, @@ -472,10 +478,14 @@ export class EventStore implements IEventStore { featureName: row.feature_name, project: row.project, environment: row.environment, + groupType: row.group_type || undefined, + groupId: row.group_id || undefined, }; } eventToDbRow(e: IBaseEvent): Omit { + const transactionContext = this.db.userParams; + return { type: e.type, created_by: e.createdBy ?? 'admin', @@ -490,6 +500,8 @@ export class EventStore implements IEventStore { project: e.project, environment: e.environment, ip: e.ip, + group_type: transactionContext?.type || null, + group_id: transactionContext?.id || null, }; } diff --git a/src/lib/openapi/spec/event-schema.ts b/src/lib/openapi/spec/event-schema.ts index 03ad0e740b..e0c38e212a 100644 --- a/src/lib/openapi/spec/event-schema.ts +++ b/src/lib/openapi/spec/event-schema.ts @@ -109,6 +109,18 @@ export const eventSchema = { 'The IP address of the user that created the event. Only available in Enterprise.', example: '192.168.1.1', }, + groupType: { + type: 'string', + description: + 'The type of transaction group this event belongs to, if applicable.', + example: 'change-request', + }, + groupId: { + type: 'string', + description: + 'The unique identifier for the transaction group this event belongs to, if applicable.', + example: '01HQVX5K8P9EXAMPLE123456', + }, }, components: { schemas: { diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index 81a0def9a2..9e9207cdf5 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -18,6 +18,11 @@ import dbInit, { type ITestDb } from '../helpers/database-init.js'; import getLogger from '../../fixtures/no-logger.js'; import type { IEventStore } from '../../../lib/types/stores/event-store.js'; import type { IAuditUser, IUnleashStores } from '../../../lib/types/index.js'; +import { + withTransactional, + type TransactionUserParams, +} from '../../../lib/db/transaction.js'; +import { EventStore } from '../../../lib/features/events/event-store.js'; import { vi } from 'vitest'; @@ -472,3 +477,155 @@ test('Should return empty result when filtering by non-existent ID', async () => expect(filteredEvents).toHaveLength(0); }); + +test('Should store and retrieve transaction context fields', async () => { + const mockTransactionContext: TransactionUserParams = { + type: 'change-request', + id: '01HQVX5K8P9EXAMPLE123456', + }; + + const eventStoreService = withTransactional( + (db) => new EventStore(db, getLogger), + db.rawDatabase, + ); + + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'test-feature-with-context', + project: 'test-project', + ip: '127.0.0.1', + data: { + name: 'test-feature-with-context', + enabled: true, + strategies: [{ name: 'default' }], + }, + }; + + await eventStoreService.transactional(async (transactionalEventStore) => { + await transactionalEventStore.store(event); + }, mockTransactionContext); + + const events = await eventStore.getAll(); + const storedEvent = events.find( + (e) => e.featureName === 'test-feature-with-context', + ); + + expect(storedEvent).toBeTruthy(); + expect(storedEvent!.groupType).toBe('change-request'); + expect(storedEvent!.groupId).toBe('01HQVX5K8P9EXAMPLE123456'); +}); + +test('Should handle missing transaction context gracefully', async () => { + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'test-feature-no-context', + project: 'test-project', + ip: '127.0.0.1', + data: { + name: 'test-feature-no-context', + enabled: true, + strategies: [{ name: 'default' }], + }, + }; + + await eventStore.store(event); + + const events = await eventStore.getAll(); + const storedEvent = events.find( + (e) => e.featureName === 'test-feature-no-context', + ); + + expect(storedEvent).toBeTruthy(); + expect(storedEvent!.groupType).toBeUndefined(); + expect(storedEvent!.groupId).toBeUndefined(); +}); + +test('Should store transaction context in batch operations', async () => { + const mockTransactionContext: TransactionUserParams = { + type: 'transaction', + id: '01HQVX5K8P9BATCH123456', + }; + + const eventStoreService = withTransactional( + (db) => new EventStore(db, getLogger), + db.rawDatabase, + ); + + const events = [ + { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'batch-feature-1', + project: 'test-project', + ip: '127.0.0.1', + data: { name: 'batch-feature-1' }, + }, + { + type: FEATURE_UPDATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'batch-feature-2', + project: 'test-project', + ip: '127.0.0.1', + data: { name: 'batch-feature-2' }, + }, + ]; + + await eventStoreService.transactional(async (transactionalEventStore) => { + await transactionalEventStore.batchStore(events); + }, mockTransactionContext); + + const allEvents = await eventStore.getAll(); + const batchEvents = allEvents.filter( + (e) => + e.featureName === 'batch-feature-1' || + e.featureName === 'batch-feature-2', + ); + + expect(batchEvents).toHaveLength(2); + batchEvents.forEach((event) => { + expect(event.groupType).toBe('transaction'); + expect(event.groupId).toBe('01HQVX5K8P9BATCH123456'); + }); +}); + +test('Should auto-generate transaction context when none provided', async () => { + const eventStoreService = withTransactional( + (db) => new EventStore(db, getLogger), + db.rawDatabase, + ); + + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'test-feature-auto-context', + project: 'test-project', + ip: '127.0.0.1', + data: { + name: 'test-feature-auto-context', + enabled: true, + strategies: [{ name: 'default' }], + }, + }; + + await eventStoreService.transactional(async (transactionalEventStore) => { + await transactionalEventStore.store(event); + }); + + const events = await eventStore.getAll(); + const storedEvent = events.find( + (e) => e.featureName === 'test-feature-auto-context', + ); + + expect(storedEvent).toBeTruthy(); + expect(storedEvent!.groupType).toBe('transaction'); + expect(storedEvent!.groupId).toBeTruthy(); + expect(typeof storedEvent!.groupId).toBe('string'); + expect(storedEvent!.groupId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); +}); From 2b23cb12465ae497297f0eac0d157b863011cd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 30 Jun 2025 15:27:35 +0100 Subject: [PATCH 19/20] chore: add permissions to ai flag cleanup workflow (#10249) --- .github/workflows/ai-flag-cleanup.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ai-flag-cleanup.yml b/.github/workflows/ai-flag-cleanup.yml index ede77b7b76..c35ade3a1c 100644 --- a/.github/workflows/ai-flag-cleanup.yml +++ b/.github/workflows/ai-flag-cleanup.yml @@ -3,6 +3,9 @@ on: issues: types: [labeled] +permissions: + pull-requests: write + jobs: flag-cleanup: uses: mirrajabi/aider-github-workflows/.github/workflows/aider-issue-to-pr.yml@v1.0.0 From 0a42d22c522ae8fd31d0731f5ad820ada4ace557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 30 Jun 2025 15:40:17 +0100 Subject: [PATCH 20/20] chore: add missing permissions to AI flag cleanup workflow (#10250) --- .github/workflows/ai-flag-cleanup.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ai-flag-cleanup.yml b/.github/workflows/ai-flag-cleanup.yml index c35ade3a1c..f5f45e0a04 100644 --- a/.github/workflows/ai-flag-cleanup.yml +++ b/.github/workflows/ai-flag-cleanup.yml @@ -5,6 +5,8 @@ on: permissions: pull-requests: write + contents: write + issues: read jobs: flag-cleanup:
-

You have been added to review {{{ changeRequestTitle }}}

+

You have been added to review {{{ changeRequestTitle }}} by {{{ requesterName }}} ({{{ requesterEmail }}})

Click {{{changeRequestLink}}} to review it