Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 113 additions & 9 deletions frontend/common/providers/withSegmentOverrides.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import data from 'common/data/base/_data'
import ProjectStore from 'common/stores/project-store'
import FeatureListStore from 'common/stores/feature-list-store'
import { mergeChangeSets } from 'common/services/useChangeRequest'

export default (WrappedComponent) => {
class HOC extends React.Component {
Expand Down Expand Up @@ -28,6 +29,7 @@ export default (WrappedComponent) => {
getOverrides = () => {
if (this.props.projectFlag) {
//todo: migrate to useSegmentFeatureState
const projectId = this.props.projectFlag.project
Promise.all([
data.get(
`${
Expand All @@ -43,7 +45,12 @@ export default (WrappedComponent) => {
this.props.environmentId,
)}&feature=${this.props.projectFlag.id}`,
),
]).then(([res, res2]) => {
this.props.changeRequest?.change_sets
? data.get(
`${Project.api}projects/${projectId}/segments/?page_size=1000`,
)
: Promise.resolve({ results: [] }),
]).then(([res, res2, segmentsRes]) => {
const results = res.results
const featureStates = res2.results
const environmentOverride = res2.results.find(
Expand Down Expand Up @@ -72,18 +79,115 @@ export default (WrappedComponent) => {
}
}
})
const resResults = res.results || []
const segmentOverrides = results
.concat(

let segmentOverrides
if (this.props.changeRequest?.change_sets) {
// Add changesets to existing segment overrides
const mergedFeatureStates = mergeChangeSets(
this.props.changeRequest.change_sets,
featureStates,
this.props.changeRequest.conflicts,
)

// Get segment IDs marked for deletion
const segmentIdsToDelete =
this.props.changeRequest.change_sets?.flatMap(
(changeSet) => changeSet.segment_ids_to_delete_overrides || [],
) || []

segmentOverrides = results.map((currentSegmentOverride) => {
const changedFeatureState = mergedFeatureStates.find(
(featureState) =>
featureState.feature_segment?.segment ===
currentSegmentOverride.segment,
)

// Any segment_ids_to_delete_overrides should be marked as toRemove
const toRemove = segmentIdsToDelete.includes(
currentSegmentOverride.segment,
)

if (changedFeatureState) {
return {
...currentSegmentOverride,
...changedFeatureState,
id: changedFeatureState.id || currentSegmentOverride.id,
is_feature_specific:
changedFeatureState.feature_segment?.is_feature_specific,
multivariate_options:
changedFeatureState.multivariate_feature_state_values || [],
priority: changedFeatureState.feature_segment?.priority || 0,
segment: changedFeatureState.feature_segment?.segment,
segment_name:
changedFeatureState.feature_segment?.segment_name ||
currentSegmentOverride.segment_name,
toRemove,
uuid:
changedFeatureState.feature_segment?.uuid ||
currentSegmentOverride.uuid,
value: Utils.featureStateToValue(
changedFeatureState.feature_state_value,
),
}
}

return toRemove
? { ...currentSegmentOverride, toRemove }
: currentSegmentOverride
})

// Add any new segment overrides from the changesets
mergedFeatureStates
.filter(
(featureState) =>
!!featureState.feature_segment?.segment &&
!results.find(
(currentOverride) =>
currentOverride.segment ===
featureState.feature_segment.segment,
),
)
.forEach((newFeatureState) => {
// Look up segment metadata from segments API to get segment name
const segmentMetadata = segmentsRes.results?.find(
(segment) =>
segment.id === newFeatureState.feature_segment?.segment,
)

segmentOverrides.push({
enabled: newFeatureState.enabled,
environment: newFeatureState.environment,
feature: newFeatureState.feature,
id: newFeatureState.id,
is_feature_specific:
newFeatureState.feature_segment?.is_feature_specific,
multivariate_options:
newFeatureState.multivariate_feature_state_values || [],
priority: newFeatureState.feature_segment?.priority || 0,
segment: newFeatureState.feature_segment?.segment,
segment_name:
newFeatureState.feature_segment?.segment_name ||
segmentMetadata?.name ||
'Unknown Segment',
uuid: newFeatureState.feature_segment?.uuid,
value: Utils.featureStateToValue(
newFeatureState.feature_state_value,
),
})
})
} else {
segmentOverrides = results.concat(
(this.props.newSegmentOverrides || []).map((v, i) => ({
...v,
})),
)
.map((v, i) => ({
...v,
originalPriority: i,
priority: i,
}))
}
segmentOverrides = segmentOverrides.map((v, i) => ({
...v,
originalPriority: i,
priority: i,
}))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Segment override priorities from changeset are discarded

When editing a versioned change request, segment override priorities from the changeset are set at lines 119 and 166 (priority: changedFeatureState.feature_segment?.priority || 0), but then immediately overwritten with array indices at lines 185-189. Since the save function at create-feature/index.js:604 uses the top-level priority property for feature_segment.priority, any priority reordering specified in the original change request will be lost when the edit is saved. The segments also remain in the current live order rather than being sorted by the changeset's intended priorities.

Additional Locations (1)

Fix in Cursor Fix in Web


const originalSegmentOverrides = _.cloneDeep(segmentOverrides)
this.setState({
environmentVariations:
Expand Down
2 changes: 1 addition & 1 deletion frontend/web/components/diff/DiffSegmentOverrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const DiffSegmentOverrides: FC<DiffSegmentOverridesType> = ({
<TabItem
className='p-0'
tabLabel={
<div className='d-flex gap-2'>
<div className='d-flex align-items-center'>
Created <div className='unread'>{created.length}</div>
</div>
}
Expand Down
6 changes: 4 additions & 2 deletions frontend/web/components/modals/ChangeRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { Req } from 'common/types/requests'
import getUserDisplayName from 'common/utils/getUserDisplayName'
import ChangeRequestConflictCheck from 'components/ChangeRequestConflictCheck'
import { getChangeRequestLiveDate } from 'common/utils/getChangeRequestLiveDate'

interface ChangeRequestModalProps {
changeRequest?: ChangeRequest
Expand Down Expand Up @@ -58,7 +59,7 @@ const ChangeRequestModal: FC<ChangeRequestModalProps> = ({
)
// const [groups, setGroups] = useState([])
const [liveFrom, setLiveFrom] = useState(
changeRequest?.feature_states[0]?.live_from,
getChangeRequestLiveDate(changeRequest)?.toISOString(),
)
const [title, setTitle] = useState(changeRequest?.title ?? '')
const [showUsers, setShowUsers] = useState(false)
Expand All @@ -72,7 +73,8 @@ const ChangeRequestModal: FC<ChangeRequestModalProps> = ({
})

useEffect(() => {
const currLiveFromDate = changeRequest?.feature_states[0]?.live_from
const currLiveFromDate =
getChangeRequestLiveDate(changeRequest)?.toISOString()
if (!currLiveFromDate) {
return setLiveFrom(showAssignees ? currDate.toISOString() : undefined)
}
Expand Down
17 changes: 14 additions & 3 deletions frontend/web/components/modals/create-feature/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ const Index = class extends Component {
projectFlag.multivariate_options.length &&
controlValue < 0
const existingChangeRequest = this.props.changeRequest
const isVersionedChangeRequest = existingChangeRequest && isVersioned
const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab()
const noPermissions = this.props.noPermissions
let regexValid = true
Expand Down Expand Up @@ -649,6 +650,10 @@ const Index = class extends Component {
this.fetchChangeRequests(true)
this.fetchScheduledChangeRequests(true)
}

if (this.props.changeRequest) {
this.close()
}
}}
>
{(
Expand Down Expand Up @@ -751,7 +756,8 @@ const Index = class extends Component {
description,
featureStateId:
this.props.changeRequest &&
this.props.changeRequest.feature_states[0].id,
this.props.changeRequest.feature_states?.[0]
?.id,
id:
this.props.changeRequest &&
this.props.changeRequest.id,
Expand Down Expand Up @@ -918,7 +924,8 @@ const Index = class extends Component {
onSaveFeatureValue={saveFeatureValue}
/>
</TabItem>
{!existingChangeRequest && (
{(!existingChangeRequest ||
isVersionedChangeRequest) && (
<TabItem
data-test='segment_overrides'
tabLabelString='Segment Overrides'
Expand Down Expand Up @@ -1930,10 +1937,14 @@ const FeatureProvider = (WrappedComponent) => {
toast('Error updating the Flag', 'danger')
return
} else {
const isEditingChangeRequest =
this.props.changeRequest && changeRequest
const operation = createdFlag || isCreate ? 'Created' : 'Updated'
const type = changeRequest ? 'Change Request' : 'Feature'

const toastText = `${operation} ${type}`
const toastText = isEditingChangeRequest
? `Updated ${type}`
: `${operation} ${type}`
const toastAction = changeRequest
? {
buttonText: 'Open',
Expand Down
68 changes: 55 additions & 13 deletions frontend/web/components/pages/ChangeRequestDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { useGetMyGroupsQuery } from 'common/services/useMyGroup'
import CreateFeatureModal from 'components/modals/create-feature'
import AccountStore from 'common/stores/account-store'
import AppActions from 'common/dispatcher/app-actions'
import { mergeChangeSets } from 'common/services/useChangeRequest'
import { getFeatureStates } from 'common/services/useFeatureState'
import { getStore } from 'common/store'
import {
ChangeRequest,
Environment,
Expand Down Expand Up @@ -156,29 +159,67 @@ const ChangeRequestDetailPage: FC<ChangeRequestPageType> = ({ match }) => {
})
}

const editChangeRequest = (
const editChangeRequest = async (
projectFlag: ProjectFlag,
environmentFlag: FeatureState,
) => {
if (!changeRequest) return

const environment: Environment = ProjectStore.getEnvironment(
environmentId,
) as any

const isVersioned = !!environment?.use_v2_feature_versioning
let changedEnvironmentFlag = environmentFlag

if (isVersioned && changeRequest.change_sets) {
// Convert the changesets into a feature state
const currentFeatureStatesResponse = await getFeatureStates(getStore(), {
environment: environment.id,
feature: projectFlag.id,
})
const mergedStates = mergeChangeSets(
changeRequest.change_sets,
currentFeatureStatesResponse.data.results,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing null check causes crash when API call fails

The editChangeRequest function calls getFeatureStates and immediately accesses currentFeatureStatesResponse.data.results without checking if data exists. When an RTK Query endpoint fails (network error, API error, etc.), the response object has data: undefined and an error property instead. This will cause a TypeError crash when attempting to access .results on undefined. The codebase uses optional chaining (data?.results) elsewhere for similar patterns, suggesting this check was inadvertently omitted here.

Fix in Cursor Fix in Web

changeRequest.conflicts,
)
const mergedEnvFlag = mergedStates.find(
(v) => !v.feature_segment?.segment,
)
if (mergedEnvFlag) {
changedEnvironmentFlag = {
...environmentFlag,
...mergedEnvFlag,
feature_state_value: Utils.featureStateToValue(
mergedEnvFlag.feature_state_value,
),
}
}
} else if (!isVersioned && changeRequest.feature_states?.[0]) {
changedEnvironmentFlag = {
...environmentFlag,
enabled: changeRequest.feature_states[0].enabled,
feature_state_value: Utils.featureStateToValue(
changeRequest.feature_states[0].feature_state_value,
),
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Legacy change requests ignored in versioned environments

The condition logic creates a gap for legacy change requests. The first branch requires isVersioned && changeRequest.change_sets, and the second requires !isVersioned && changeRequest.feature_states?.[0]. If an environment has versioning enabled but a change request was created before versioning (using feature_states instead of change_sets), neither branch executes. The changedEnvironmentFlag would retain the current live values rather than the change request's intended values, causing the edit modal to display incorrect data.

Fix in Cursor Fix in Web


openModal(
'Edit Change Request',
<CreateFeatureModal
history={history}
environmentId={environmentId}
projectId={projectId}
schangeRequest={changeRequest}
changeRequest={changeRequest}
projectFlag={projectFlag}
environmentFlag={changedEnvironmentFlag}
multivariate_options={
changeRequest.feature_states[0].multivariate_feature_state_values
!isVersioned
? changeRequest.feature_states?.[0]
?.multivariate_feature_state_values
: undefined
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Versioned CR edit loses multivariate allocation values

When editing a versioned change request, multivariate_options is explicitly passed as undefined. During save, this causes a fallback to projectFlag.multivariate_options (project defaults) instead of preserving the changeset's multivariate allocation values. If the original change request modified multivariate percentage allocations, those modifications would be lost and replaced with default values when the edited change request is saved.

Additional Locations (1)

Fix in Cursor Fix in Web

environmentFlag={{
...environmentFlag,
enabled: changeRequest.feature_states[0].enabled,
feature_state_value: Utils.featureStateToValue(
changeRequest.feature_states[0].feature_state_value,
),
}}
flagId={environmentFlag.id}
/>,
'side-modal create-feature-modal',
Expand Down Expand Up @@ -310,7 +351,7 @@ const ChangeRequestDetailPage: FC<ChangeRequestPageType> = ({ match }) => {
scheduledDate={getChangeRequestLiveDate(changeRequest)}
deleteChangeRequest={deleteChangeRequest}
editChangeRequest={
!isVersioned && !changeRequest?.committed_at
!changeRequest?.committed_at
? () => editChangeRequest(projectFlag, environmentFlag)
: undefined
}
Expand Down Expand Up @@ -494,12 +535,13 @@ export const ChangeRequestPageInner: FC<ChangeRequestPageInnerType> = ({
changeRequest &&
changeRequest.user &&
orgUsers.find((v) => v.id === changeRequest.user)

const isYours = AccountStore.getUserId() === changeRequest.user
return (
<div>
<PageTitle
cta={
(!changeRequest.committed_at || isScheduled) && (
(!changeRequest.committed_at || isScheduled) &&
isYours && (
<Row>
<Button theme='secondary' onClick={deleteChangeRequest}>
Delete
Expand Down
Loading