Skip to content

Commit cd94fbe

Browse files
talissoncostaclaude
andcommitted
feat(dependencies): add the API layer for staging dependencies in a change request
Real endpoints to create and delete a change request, and an optional changeRequestId on adding and removing a dependency. Staging is faked until #8449: the API ignores ?change_request today and would publish the change live, so those calls go to fakeChangeRequestDependencies.ts. Refs #8450 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2603ae5 commit cd94fbe

10 files changed

Lines changed: 568 additions & 14 deletions
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { DependencyEdge } from 'common/types/responses'
2+
import {
3+
discardChangeRequest,
4+
applyStagedChanges,
5+
getChangeRequestIdsStaging,
6+
getFeatureIdsStaged,
7+
getStagedDependencyChanges,
8+
stageAdd,
9+
stageRemove,
10+
} from 'common/services/fakeChangeRequestDependencies'
11+
12+
const edge = (featureId: number, prerequisiteId: number) =>
13+
({
14+
feature: { id: featureId, name: `feature_${featureId}` },
15+
prerequisite: { id: prerequisiteId, name: `feature_${prerequisiteId}` },
16+
} as unknown as DependencyEdge)
17+
18+
const prerequisite = { id: 3, name: 'feature_3' }
19+
const args = {
20+
changeRequestId: 12,
21+
dependentEdges: [],
22+
featureId: 1,
23+
liveEdges: [],
24+
prerequisite,
25+
prerequisiteEdges: [],
26+
}
27+
28+
beforeEach(() => {
29+
const store: Record<string, string> = {}
30+
globalThis.localStorage = {
31+
getItem: (key: string) => store[key] ?? null,
32+
setItem: (key: string, value: string) => {
33+
store[key] = value
34+
},
35+
} as Storage
36+
})
37+
38+
describe('stageAdd', () => {
39+
it('holds the add against the change request and feature', () => {
40+
expect(stageAdd(args)).toBeNull()
41+
expect(getStagedDependencyChanges(12, 1)).toEqual([
42+
{ action: 'add', prerequisite },
43+
])
44+
expect(getStagedDependencyChanges(12, 2)).toEqual([])
45+
})
46+
47+
it('refuses a prerequisite that is already live', () => {
48+
expect(stageAdd({ ...args, liveEdges: [edge(1, 3)] })?.code).toBe(
49+
'dependency_exists',
50+
)
51+
})
52+
53+
it('refuses the same prerequisite staged twice', () => {
54+
stageAdd(args)
55+
expect(stageAdd(args)?.code).toBe('dependency_exists')
56+
})
57+
58+
it('refuses a prerequisite that has prerequisites of its own', () => {
59+
expect(
60+
stageAdd({ ...args, prerequisiteEdges: [edge(3, 9)] })?.message,
61+
).toBe(
62+
'The prerequisite "feature_3" already has a prerequisite "feature_9".',
63+
)
64+
})
65+
})
66+
67+
describe('stageAdd, a feature that is a prerequisite', () => {
68+
it('refuses, naming the feature that depends on it', () => {
69+
expect(stageAdd({ ...args, dependentEdges: [edge(5, 1)] })).toEqual({
70+
code: 'feature_is_prerequisite',
71+
message:
72+
'The feature "feature_1" is already a prerequisite for the feature "feature_5".',
73+
})
74+
})
75+
})
76+
77+
describe('stageRemove', () => {
78+
it('drops a staged add rather than staging both', () => {
79+
stageAdd(args)
80+
stageRemove(12, 1, prerequisite)
81+
expect(getStagedDependencyChanges(12, 1)).toEqual([])
82+
})
83+
84+
it('stages the removal of a live prerequisite', () => {
85+
stageRemove(12, 1, prerequisite)
86+
expect(getStagedDependencyChanges(12, 1)).toEqual([
87+
{ action: 'remove', prerequisite },
88+
])
89+
})
90+
})
91+
92+
describe('staged ids', () => {
93+
it('lists change requests by feature, and features by change request', () => {
94+
stageAdd(args)
95+
stageRemove(14, 1, { id: 4, name: 'feature_4' })
96+
stageAdd({ ...args, featureId: 2 })
97+
expect(getChangeRequestIdsStaging(1)).toEqual([12, 14])
98+
expect(getFeatureIdsStaged(12)).toEqual([1, 2])
99+
})
100+
101+
it('drops a change request emptied by its own removals', () => {
102+
stageAdd(args)
103+
stageRemove(12, 1, prerequisite)
104+
expect(getChangeRequestIdsStaging(1)).toEqual([])
105+
})
106+
107+
it('forgets every feature of a discarded change request', () => {
108+
stageAdd(args)
109+
stageAdd({ ...args, featureId: 2 })
110+
stageAdd({ ...args, changeRequestId: 120 })
111+
discardChangeRequest(12)
112+
expect(getFeatureIdsStaged(12)).toEqual([])
113+
expect(getFeatureIdsStaged(120)).toHaveLength(1)
114+
})
115+
})
116+
117+
describe('applyStagedChanges', () => {
118+
it('lists the dependencies as if the change request were live', () => {
119+
stageAdd(args)
120+
stageRemove(12, 1, { id: 4, name: 'feature_4' })
121+
expect(
122+
applyStagedChanges(12, 1, [edge(1, 4), edge(1, 5)]).map(
123+
(e) => e.prerequisite.id,
124+
),
125+
).toEqual([5, 3])
126+
})
127+
128+
it('leaves the live list alone for another change request', () => {
129+
stageAdd(args)
130+
expect(applyStagedChanges(99, 1, [edge(1, 5)])).toEqual([edge(1, 5)])
131+
})
132+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Stands in for staging a dependency in a change request (#8449) until the API
2+
// supports it. The API ignores ?change_request today and would publish the
3+
// change live, so a staged change never reaches it. Delete this file, and the
4+
// branches in useFeatureDependency that call it, once #8449 ships.
5+
import {
6+
DependencyConflictCode,
7+
DependencyEdge,
8+
DependencyFeature,
9+
StagedDependencyChange,
10+
} from 'common/types/responses'
11+
import { stagedDependencyEdge } from 'common/utils/stagedDependencyEdge'
12+
13+
type StagedChanges = Record<string, StagedDependencyChange[]>
14+
15+
// In local storage so the change request page can read what the feature modal
16+
// staged, across navigations and reloads.
17+
const STORAGE_KEY = 'fakeChangeRequestDependencies'
18+
19+
const read = (): StagedChanges => {
20+
try {
21+
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
22+
} catch {
23+
return {}
24+
}
25+
}
26+
27+
const write = (changes: StagedChanges) => {
28+
try {
29+
localStorage.setItem(STORAGE_KEY, JSON.stringify(changes))
30+
} catch {
31+
// Private mode or blocked storage: the change is lost on reload.
32+
}
33+
}
34+
35+
const keyOf = (changeRequestId: number, featureId: number) =>
36+
`${changeRequestId}-${featureId}`
37+
38+
export const getStagedDependencyChanges = (
39+
changeRequestId: number,
40+
featureId: number,
41+
): StagedDependencyChange[] => read()[keyOf(changeRequestId, featureId)] ?? []
42+
43+
export type FakeConflict = { code: DependencyConflictCode; message: string }
44+
45+
type StageArgs = {
46+
changeRequestId: number
47+
featureId: number
48+
prerequisite: DependencyFeature
49+
// What is live today, read from the real API by the caller.
50+
liveEdges: DependencyEdge[]
51+
prerequisiteEdges: DependencyEdge[]
52+
dependentEdges: DependencyEdge[]
53+
}
54+
55+
// The same refusals the API gives a direct add, checked against live data.
56+
export const stageAdd = ({
57+
changeRequestId,
58+
dependentEdges,
59+
featureId,
60+
liveEdges,
61+
prerequisite,
62+
prerequisiteEdges,
63+
}: StageArgs): FakeConflict | null => {
64+
const changes = read()
65+
const key = keyOf(changeRequestId, featureId)
66+
const staged = changes[key] ?? []
67+
const isLive = liveEdges.some((e) => e.prerequisite.id === prerequisite.id)
68+
if (isLive || staged.some((c) => c.prerequisite.id === prerequisite.id)) {
69+
return {
70+
code: 'dependency_exists',
71+
message: `"${prerequisite.name}" is already a prerequisite.`,
72+
}
73+
}
74+
if (dependentEdges.length) {
75+
const [edge] = dependentEdges
76+
return {
77+
code: 'feature_is_prerequisite',
78+
message: `The feature "${edge.prerequisite.name}" is already a prerequisite for the feature "${edge.feature.name}".`,
79+
}
80+
}
81+
const ownEdge = prerequisiteEdges.find(
82+
(e) => e.feature.id === prerequisite.id,
83+
)
84+
if (ownEdge) {
85+
return {
86+
code: 'prerequisite_has_prerequisite',
87+
message: `The prerequisite "${prerequisite.name}" already has a prerequisite "${ownEdge.prerequisite.name}".`,
88+
}
89+
}
90+
changes[key] = [...staged, { action: 'add', prerequisite }]
91+
write(changes)
92+
return null
93+
}
94+
95+
// Removing a staged add drops it rather than staging both, as #8449 specifies.
96+
export const stageRemove = (
97+
changeRequestId: number,
98+
featureId: number,
99+
prerequisite: DependencyFeature,
100+
) => {
101+
const changes = read()
102+
const key = keyOf(changeRequestId, featureId)
103+
const staged = changes[key] ?? []
104+
const stagedAdd = staged.some(
105+
(c) => c.action === 'add' && c.prerequisite.id === prerequisite.id,
106+
)
107+
changes[key] = stagedAdd
108+
? staged.filter((c) => c.prerequisite.id !== prerequisite.id)
109+
: [...staged, { action: 'remove', prerequisite }]
110+
write(changes)
111+
}
112+
113+
// As #8720 answers GET ?change_request: the list as if the change request
114+
// were live.
115+
export const applyStagedChanges = (
116+
changeRequestId: number,
117+
featureId: number,
118+
liveEdges: DependencyEdge[],
119+
): DependencyEdge[] => {
120+
const staged = getStagedDependencyChanges(changeRequestId, featureId)
121+
const removed = (id: number) =>
122+
staged.some((c) => c.action === 'remove' && c.prerequisite.id === id)
123+
return [
124+
...liveEdges.filter((edge) => !removed(edge.prerequisite.id)),
125+
...staged
126+
.filter((c) => c.action === 'add')
127+
.map((c) => stagedDependencyEdge(featureId, c.prerequisite)),
128+
]
129+
}
130+
131+
const stagedKeys = () =>
132+
Object.entries(read())
133+
.filter(([, changes]) => changes.length)
134+
.map(([key]) => key.split('-').map(Number))
135+
136+
// Stand in for listing change requests by feature, and a change request's
137+
// change sets, until real change sets carry the staged dependencies.
138+
export const getChangeRequestIdsStaging = (featureId: number) =>
139+
stagedKeys()
140+
.filter(([, id]) => id === featureId)
141+
.map(([changeRequestId]) => changeRequestId)
142+
143+
export const getFeatureIdsStaged = (changeRequestId: number) =>
144+
stagedKeys()
145+
.filter(([id]) => id === changeRequestId)
146+
.map(([, featureId]) => featureId)
147+
148+
// A deleted change request takes its staged changes with it.
149+
export const discardChangeRequest = (changeRequestId: number) => {
150+
const changes = read()
151+
Object.keys(changes)
152+
.filter((key) => key.startsWith(`${changeRequestId}-`))
153+
.forEach((key) => delete changes[key])
154+
write(changes)
155+
}

‎frontend/common/services/useChangeRequest.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,31 @@ export const changeRequestService = service
1515
.enhanceEndpoints({ addTagTypes: ['ChangeRequest'] })
1616
.injectEndpoints({
1717
endpoints: (builder) => ({
18+
createEnvironmentChangeRequest: builder.mutation<
19+
Res['createEnvironmentChangeRequest'],
20+
Req['createEnvironmentChangeRequest']
21+
>({
22+
invalidatesTags: [{ id: 'LIST', type: 'ChangeRequest' }],
23+
query: ({ approvals, environmentId, ...rest }) => ({
24+
body: {
25+
...rest,
26+
// The modal hands back users and groups in one list.
27+
approvals: approvals.filter((approval) => !!approval.user),
28+
// Required even when the changes are in change sets.
29+
feature_states: [],
30+
group_assignments: approvals.filter((approval) => !!approval.group),
31+
},
32+
method: 'POST',
33+
url: `environments/${environmentId}/create-change-request/`,
34+
}),
35+
}),
36+
deleteChangeRequest: builder.mutation<void, Req['deleteChangeRequest']>({
37+
invalidatesTags: [{ id: 'LIST', type: 'ChangeRequest' }],
38+
query: ({ id }) => ({
39+
method: 'DELETE',
40+
url: `features/workflows/change-requests/${id}/`,
41+
}),
42+
}),
1843
getChangeRequests: builder.query<
1944
Res['changeRequests'],
2045
Req['getChangeRequests']
@@ -67,6 +92,8 @@ export async function updateChangeRequest(
6792
// END OF FUNCTION_EXPORTS
6893

6994
export const {
95+
useCreateEnvironmentChangeRequestMutation,
96+
useDeleteChangeRequestMutation,
7097
useGetChangeRequestsQuery,
7198
useUpdateChangeRequestMutation,
7299
// END OF EXPORTS

0 commit comments

Comments
 (0)