Skip to content

Commit 0511616

Browse files
talissoncostaclaude
andcommitted
feat(dependencies): stage dependency changes in a change request
Where an environment requires change requests, the Dependencies tab holds adds and removes until a change request is created, then stages each one in it. If any is refused, the change request is deleted and the refused row is marked. Environments on v1 feature versioning get a read-only tab. As the API refuses an empty change request, it carries the feature's current value unchanged. Gated behind flag_dependencies_change_requests. Refs #8450 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent db24d85 commit 0511616

14 files changed

Lines changed: 593 additions & 35 deletions

‎frontend/web/components/modals/create-feature/index.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,12 +460,13 @@ const CreateFeatureModal: FC<CreateFeatureModalProps> = (props) => {
460460
controlValue < 0
461461
const isVersionedChangeRequest = existingChangeRequest && isVersioned
462462
const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab()
463-
// Change requests are out of scope for the first version, so the tab stays
464-
// hidden where they are enforced. See #8428.
463+
// Where change requests are enforced, the tab waits on its own flag while the
464+
// API for staging dependencies (#8449) is faked.
465465
const isDependenciesTabEnabled =
466466
Utils.getFlagsmithHasFeature('flag_dependencies') &&
467467
!existingChangeRequest &&
468-
!is4Eyes
468+
(!is4Eyes ||
469+
Utils.getFlagsmithHasFeature('flag_dependencies_change_requests'))
469470

470471
let regexValid = true
471472
try {
@@ -835,6 +836,8 @@ const CreateFeatureModal: FC<CreateFeatureModalProps> = (props) => {
835836
environmentName={environmentName}
836837
projectId={projectId}
837838
projectFlag={projectFlag}
839+
requiresChangeRequests={is4Eyes}
840+
isVersioned={isVersioned}
838841
onSelectFeature={(featureId) => {
839842
// The features list, never the current page: it
840843
// owns the deep link and hydrates an off-page
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { FC } from 'react'
2+
import Button from 'components/base/forms/Button'
3+
4+
type ChangeRequestFooterProps = {
5+
count: number
6+
isSubmitting: boolean
7+
onCreate: () => void
8+
onDiscard: () => void
9+
}
10+
11+
const ChangeRequestFooter: FC<ChangeRequestFooterProps> = ({
12+
count,
13+
isSubmitting,
14+
onCreate,
15+
onDiscard,
16+
}) => (
17+
<div className='d-flex align-items-center gap-2 mt-3'>
18+
<span className='text-secondary me-auto'>
19+
{count} {count === 1 ? 'change' : 'changes'} staged
20+
</span>
21+
<Button theme='secondary' onClick={onDiscard} disabled={isSubmitting}>
22+
Discard changes
23+
</Button>
24+
<Button onClick={onCreate} isLoading={isSubmitting} disabled={isSubmitting}>
25+
Create Change Request
26+
</Button>
27+
</div>
28+
)
29+
30+
export default ChangeRequestFooter
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { FC } from 'react'
2+
import Banner from 'components/base/Banner'
3+
import Button from 'components/base/forms/Button'
4+
import { DependenciesMode } from './dependenciesMode'
5+
6+
type ChangeRequestNoticeProps = {
7+
mode: DependenciesMode
8+
environmentId: string
9+
environmentName: string
10+
projectId: number
11+
}
12+
13+
const ChangeRequestNotice: FC<ChangeRequestNoticeProps> = ({
14+
environmentId,
15+
environmentName,
16+
mode,
17+
projectId,
18+
}) => {
19+
if (mode === 'changeRequest') {
20+
return (
21+
<Banner className='mb-3'>
22+
{environmentName} requires change requests. Dependency changes are
23+
staged here and go live when the change request is published.
24+
</Banner>
25+
)
26+
}
27+
if (mode === 'readOnly') {
28+
return (
29+
<Banner
30+
type='warning'
31+
className='mb-3'
32+
title={`Read only in ${environmentName}`}
33+
action={
34+
<Button
35+
theme='outline'
36+
size='small'
37+
href={`/project/${projectId}/environment/${environmentId}/settings`}
38+
>
39+
Enable feature versioning
40+
</Button>
41+
}
42+
>
43+
Dependency changes need feature versioning to go through a change
44+
request.
45+
</Banner>
46+
)
47+
}
48+
return null
49+
}
50+
51+
export default ChangeRequestNotice

‎frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx‎

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import DependentFeatures from './DependentFeatures'
1717
import FeatureDependenciesSkeleton from './FeatureDependenciesSkeleton'
1818
import PrerequisitesTable from './PrerequisitesTable'
1919
import { PrerequisitesEmptyState } from './DependenciesEmptyStates'
20+
import ChangeRequestFooter from './ChangeRequestFooter'
21+
import ChangeRequestNotice from './ChangeRequestNotice'
22+
import { getDependenciesMode } from './dependenciesMode'
23+
import { withStagedChanges } from './prerequisiteState'
2024
import { usePrerequisites } from './hooks/usePrerequisites'
25+
import { useStagedPrerequisites } from './hooks/useStagedPrerequisites'
2126

2227
// Flag dependencies have no docs page of their own yet, so this points at the
2328
// nearest one that exists.
@@ -29,30 +34,44 @@ type FeatureDependenciesTabProps = {
2934
environmentName: string
3035
projectId: number
3136
projectFlag: ProjectFlag
37+
requiresChangeRequests: boolean
38+
isVersioned: boolean
3239
// Opens another feature's modal on this tab.
3340
onSelectFeature: (featureId: number) => void
3441
}
3542

3643
const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
3744
environmentId,
3845
environmentName,
46+
isVersioned,
3947
onSelectFeature,
4048
projectFlag,
4149
projectId,
50+
requiresChangeRequests,
4251
}) => {
43-
const { isLoading: isLoadingPermission, permission: canManage } =
44-
useHasPermission({
45-
id: environmentId,
46-
level: 'environment',
47-
permission: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES,
48-
})
52+
const mode = getDependenciesMode(requiresChangeRequests, isVersioned)
53+
const isChangeRequest = mode === 'changeRequest'
54+
const { isLoading: isLoadingPermission, permission } = useHasPermission({
55+
id: environmentId,
56+
level: 'environment',
57+
permission: isChangeRequest
58+
? EnvironmentPermission.CREATE_CHANGE_REQUEST
59+
: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES,
60+
})
61+
const canManage = permission && mode !== 'readOnly'
62+
const staging = useStagedPrerequisites({
63+
environmentId,
64+
projectFlag,
65+
projectId,
66+
})
4967

5068
const {
5169
addingName,
5270
conflict,
5371
dependentEdges,
5472
flashedId,
5573
isCreating,
74+
isEnabled,
5675
isError,
5776
isLoading,
5877
isOpenForAdding,
@@ -80,6 +99,16 @@ const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
8099
return <ErrorMessage error="Could not load this feature's dependencies." />
81100
}
82101

102+
const shownRows = isChangeRequest
103+
? withStagedChanges(rows, staging.staged, projectFlag.id, isEnabled)
104+
: rows
105+
const handleAdd = isChangeRequest
106+
? (feature: ProjectFlag) => {
107+
staging.add(feature)
108+
onAddingChange(false)
109+
}
110+
: onAdd
111+
83112
return (
84113
<div>
85114
<div className='d-flex align-items-center gap-1 mb-2'>
@@ -107,19 +136,28 @@ const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
107136
</div>
108137
)}
109138

139+
<ChangeRequestNotice
140+
mode={mode}
141+
environmentId={environmentId}
142+
environmentName={environmentName}
143+
projectId={projectId}
144+
/>
145+
110146
{/* addingName too: on a first add the picker has closed and no row has
111147
arrived yet, and the empty state would take the pending row's place. */}
112-
{rows.length || isOpenForAdding || addingName ? (
148+
{shownRows.length || isOpenForAdding || addingName ? (
113149
<>
114150
<BlockedBanner environmentName={environmentName} rows={rows} />
115151
<DependenciesPanel>
116152
<PrerequisitesTable
117-
rows={rows}
153+
rows={shownRows}
118154
canManage={canManage}
119155
flashedId={flashedId}
120156
addingName={addingName}
121157
isRemoving={removingId}
122-
onRemove={onRemove}
158+
refusedId={staging.error?.prerequisiteId}
159+
onRemove={isChangeRequest ? staging.remove : onRemove}
160+
onUndo={(edge) => staging.undo(edge.prerequisite.id)}
123161
onSelect={(edge) => onSelectFeature(edge.prerequisite.id)}
124162
addControl={
125163
isOpenForAdding && (
@@ -132,9 +170,9 @@ const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
132170
// list.
133171
ignore={[
134172
projectFlag.id,
135-
...rows.map((row) => row.edge.prerequisite.id),
173+
...shownRows.map((row) => row.edge.prerequisite.id),
136174
]}
137-
onChange={onAdd}
175+
onChange={handleAdd}
138176
/>
139177
)
140178
}
@@ -159,6 +197,16 @@ const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
159197
</Banner>
160198
)}
161199

200+
{!!staging.error && (
201+
<Banner
202+
type='error'
203+
className='mt-3'
204+
title='The change request was not created'
205+
>
206+
{staging.error.message}
207+
</Banner>
208+
)}
209+
162210
{!isPrerequisite && canManage && !isOpenForAdding && (
163211
<Button
164212
theme='outline'
@@ -170,6 +218,15 @@ const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
170218
</Button>
171219
)}
172220

221+
{isChangeRequest && !!staging.staged.length && (
222+
<ChangeRequestFooter
223+
count={staging.staged.length}
224+
isSubmitting={staging.isSubmitting}
225+
onCreate={staging.openChangeRequest}
226+
onDiscard={staging.discard}
227+
/>
228+
)}
229+
173230
<ModalHR className='mt-4' />
174231

175232
<DependentFeatures

‎frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.scss‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,30 @@
1515
background: var(--color-surface-muted);
1616
}
1717

18+
// A button only shrinks below its text with a min width.
19+
.prerequisite-name > .btn {
20+
min-width: 0;
21+
}
22+
23+
.prerequisite-name__text {
24+
min-width: 0;
25+
}
26+
27+
// Held for a change request: still listed, but on its way out. On the inner
28+
// span, as .btn-link clears text-decoration with !important.
29+
.prerequisite-row--staged-remove .prerequisite-name .btn-link {
30+
color: var(--color-text-secondary);
31+
}
32+
33+
.prerequisite-row--staged-remove .prerequisite-name__text {
34+
text-decoration: line-through;
35+
}
36+
37+
// On the cell, as with the input row, so hover does not replace it.
38+
.prerequisite-row--refused td {
39+
background: var(--color-surface-danger);
40+
}
41+
1842
// The select needs permission to shrink or it pushes the row past the panel.
1943
.prerequisite-add-control > :first-child {
2044
flex: 1 1 auto;

0 commit comments

Comments
 (0)