From 8d1a9a0a8787ba438b3ec1332ca0f9cd248884bb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 9 Oct 2026 16:52:41 -0300 Subject: [PATCH 01/22] 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) --- frontend/common/hooks/useFeatureStates.ts | 3 + .../modals/create-feature/index.tsx | 9 +- .../ChangeRequestFooter.tsx | 30 +++ .../ChangeRequestNotice.tsx | 61 ++++++ .../FeatureDependenciesTab.tsx | 80 ++++++-- .../PrerequisitesTable.scss | 14 ++ .../PrerequisitesTable/PrerequisitesTable.tsx | 79 ++++++-- .../__tests__/dependenciesMode.test.ts | 17 ++ .../__tests__/prerequisiteState.test.ts | 39 ++++ .../__tests__/stagingError.test.ts | 38 ++++ .../dependenciesMode.ts | 12 ++ .../hooks/useDependencies.ts | 1 + .../hooks/usePrerequisites.tsx | 13 +- .../hooks/useStagedPrerequisites.tsx | 180 ++++++++++++++++++ .../prerequisiteState.ts | 37 +++- .../FeatureDependenciesTab/stagingError.ts | 21 ++ .../unchangedChangeSet.ts | 37 ++++ 17 files changed, 636 insertions(+), 35 deletions(-) create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestFooter.tsx create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestNotice.tsx create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/dependenciesMode.test.ts create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/dependenciesMode.ts create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts diff --git a/frontend/common/hooks/useFeatureStates.ts b/frontend/common/hooks/useFeatureStates.ts index 37d66c2f8453..e87e86681ff5 100644 --- a/frontend/common/hooks/useFeatureStates.ts +++ b/frontend/common/hooks/useFeatureStates.ts @@ -41,6 +41,9 @@ export const useFeatureStates = ({ const features = data?.results ?? NO_FEATURES return { features, + isEnabled: (featureId: number) => + !!features.find((feature) => feature.id === featureId) + ?.environment_feature_state?.enabled, isError: isFeatureListError || !!environmentsError || diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 6ce0a4165751..53d77414e0c3 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -460,12 +460,13 @@ const CreateFeatureModal: FC = (props) => { controlValue < 0 const isVersionedChangeRequest = existingChangeRequest && isVersioned const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() - // Change requests are out of scope for the first version, so the tab stays - // hidden where they are enforced. See #8428. + // Where change requests are enforced, the tab waits on its own flag while the + // API for staging dependencies (#8449) is faked. const isDependenciesTabEnabled = Utils.getFlagsmithHasFeature('flag_dependencies') && !existingChangeRequest && - !is4Eyes + (!is4Eyes || + Utils.getFlagsmithHasFeature('flag_dependencies_change_requests')) let regexValid = true try { @@ -835,6 +836,8 @@ const CreateFeatureModal: FC = (props) => { environmentName={environmentName} projectId={projectId} projectFlag={projectFlag} + requiresChangeRequests={is4Eyes} + isVersioned={isVersioned} onSelectFeature={(featureId) => { // The features list, never the current page: it // owns the deep link and hydrates an off-page diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestFooter.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestFooter.tsx new file mode 100644 index 000000000000..c0b2192718a8 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestFooter.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react' +import Button from 'components/base/forms/Button' + +type ChangeRequestFooterProps = { + count: number + isSubmitting: boolean + onCreate: () => void + onDiscard: () => void +} + +const ChangeRequestFooter: FC = ({ + count, + isSubmitting, + onCreate, + onDiscard, +}) => ( +
+ + {count} {count === 1 ? 'change' : 'changes'} staged + + + +
+) + +export default ChangeRequestFooter diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestNotice.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestNotice.tsx new file mode 100644 index 000000000000..cba94fe8e7d7 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestNotice.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react' +import Banner from 'components/base/Banner' +import Button from 'components/base/forms/Button' +import { DependenciesMode } from './dependenciesMode' + +type ChangeRequestNoticeProps = { + mode: DependenciesMode + canCreate: boolean + environmentId: string + environmentName: string + projectId: number +} + +const ChangeRequestNotice: FC = ({ + canCreate, + environmentId, + environmentName, + mode, + projectId, +}) => { + if (mode === 'changeRequest' && !canCreate) { + return ( + + {environmentName} requires change requests, and you don't have + permission to create them. + + ) + } + if (mode === 'changeRequest') { + return ( + + {environmentName} requires change requests. Dependency changes are + staged here and go live when the change request is published. + + ) + } + if (mode === 'readOnly') { + return ( + + Enable feature versioning + + } + > + Dependency changes need feature versioning to go through a change + request. + + ) + } + return null +} + +export default ChangeRequestNotice diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx index 12cd6ec7140c..6f729edd2bef 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx @@ -12,15 +12,22 @@ import DependentFeatures from './DependentFeatures' import FeatureDependenciesSkeleton from './FeatureDependenciesSkeleton' import PrerequisitesTable from './PrerequisitesTable' import { PrerequisitesEmptyState } from './DependenciesEmptyStates' +import ChangeRequestFooter from './ChangeRequestFooter' +import ChangeRequestNotice from './ChangeRequestNotice' import DependenciesHeader from './DependenciesHeader' import PrerequisitePicker from './PrerequisitePicker' +import { getDependenciesMode } from './dependenciesMode' +import { withStagedChanges } from './prerequisiteState' import { usePrerequisites } from './hooks/usePrerequisites' +import { useStagedPrerequisites } from './hooks/useStagedPrerequisites' type FeatureDependenciesTabProps = { environmentId: string environmentName: string projectId: number projectFlag: ProjectFlag + requiresChangeRequests: boolean + isVersioned: boolean // Opens another feature's modal on this tab. onSelectFeature: (featureId: number) => void } @@ -28,16 +35,27 @@ type FeatureDependenciesTabProps = { const FeatureDependenciesTab: FC = ({ environmentId, environmentName, + isVersioned, onSelectFeature, projectFlag, projectId, + requiresChangeRequests, }) => { - const { isLoading: isLoadingPermission, permission: canManage } = - useHasPermission({ - id: environmentId, - level: 'environment', - permission: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, - }) + const mode = getDependenciesMode(requiresChangeRequests, isVersioned) + const isChangeRequest = mode === 'changeRequest' + const { isLoading: isLoadingPermission, permission } = useHasPermission({ + id: environmentId, + level: 'environment', + permission: isChangeRequest + ? EnvironmentPermission.CREATE_CHANGE_REQUEST + : EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + }) + const canManage = permission && mode !== 'readOnly' + const staging = useStagedPrerequisites({ + environmentId, + projectFlag, + projectId, + }) const { addingName, @@ -45,6 +63,7 @@ const FeatureDependenciesTab: FC = ({ dependentEdges, flashedId, isCreating, + isEnabled, isError, isLoading, isOpenForAdding, @@ -72,23 +91,43 @@ const FeatureDependenciesTab: FC = ({ return } + const shownRows = isChangeRequest + ? withStagedChanges(rows, staging.staged, projectFlag.id, isEnabled) + : rows + const handleAdd = isChangeRequest + ? (feature: ProjectFlag) => { + staging.add(feature) + onAddingChange(false) + } + : onAdd + return (
+ + {/* addingName too: on a first add the picker has closed and no row has arrived yet, and the empty state would take the pending row's place. */} - {rows.length || isOpenForAdding || addingName ? ( + {shownRows.length || isOpenForAdding || addingName ? ( <> staging.undo(edge.prerequisite.id)} onSelect={(edge) => onSelectFeature(edge.prerequisite.id)} addControl={ isOpenForAdding && ( @@ -96,11 +135,11 @@ const FeatureDependenciesTab: FC = ({ projectId={projectId} environmentId={environmentId} featureId={projectFlag.id} - existingPrerequisiteIds={rows.map( + existingPrerequisiteIds={shownRows.map( (row) => row.edge.prerequisite.id, )} disabled={isCreating} - onChange={onAdd} + onChange={handleAdd} /> ) } @@ -125,6 +164,16 @@ const FeatureDependenciesTab: FC = ({ )} + {!!staging.error && ( + + {staging.error.message} + + )} + {!isPrerequisite && canManage && !isOpenForAdding && ( )} + {isChangeRequest && !!staging.staged.length && ( + + )} + .btn { + min-width: 0; +} + +.prerequisite-name__text { + min-width: 0; +} + +// On the cell, as with the input row, so hover does not replace it. +.prerequisite-row--refused td { + background: var(--color-surface-danger); +} + // The select needs permission to shrink or it pushes the row past the panel. .prerequisite-add-control > :first-child { flex: 1 1 auto; diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx index 8a6689c25fec..ee1da64bab0f 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx @@ -2,6 +2,7 @@ import { FC, MouseEvent, ReactNode } from 'react' import cn from 'classnames' import { DependencyEdge } from 'common/types/responses' import Button from 'components/base/forms/Button' +import Chip from 'components/base/Chip' import Table, { TableBody, TableCell, @@ -26,6 +27,10 @@ type PrerequisitesTableProps = { flashedId?: number isRemoving?: number onRemove: (edge: DependencyEdge) => void + // Takes back a held removal. + onUndo?: (edge: DependencyEdge) => void + // The row a change request refused, marked until the user acts. + refusedId?: number onSelect: (edge: DependencyEdge) => void // The prerequisite being added, shown as a row of its own until the POST // resolves. Otherwise nothing moves between picking one and it appearing. @@ -43,6 +48,8 @@ const PrerequisitesTable: FC = ({ onCancelAdd, onRemove, onSelect, + onUndo, + refusedId, rows, }) => ( @@ -60,25 +67,47 @@ const PrerequisitesTable: FC = ({ - {rows.map(({ edge, isEnabled, isMet }) => ( + {rows.map(({ edge, isEnabled, isMet, staged }) => ( onSelect(edge)} pending={isRemoving === edge.prerequisite.id} > - - + + {/* The name gives way to the chip, so a long name cannot hide + what is about to happen to it. */} +
+ + {!!staged && ( + + {staged === 'add' ? 'Staged: add' : 'Staged: remove'} + + )} +
@@ -88,21 +117,35 @@ const PrerequisitesTable: FC = ({ {canManage && ( - {/* An edge from a hand-written segment condition cannot be removed - through the dependencies API, so it gets no control. */} - {edge.segment.is_system && ( + {staged === 'remove' && onUndo ? ( { e.stopPropagation() - onRemove(edge) + onUndo(edge) }} - aria-label={`Remove ${edge.prerequisite.name} as a prerequisite`} + aria-label={`Keep ${edge.prerequisite.name} as a prerequisite`} > - + + ) : ( + // An edge from a hand-written segment condition cannot be + // removed through the dependencies API, so it gets no control. + edge.segment.is_system && ( + { + e.stopPropagation() + onRemove(edge) + }} + aria-label={`Remove ${edge.prerequisite.name} as a prerequisite`} + > + + + ) )} )} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/dependenciesMode.test.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/dependenciesMode.test.ts new file mode 100644 index 000000000000..a3cceb07fcee --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/dependenciesMode.test.ts @@ -0,0 +1,17 @@ +import { getDependenciesMode } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/dependenciesMode' + +describe('getDependenciesMode', () => { + it.each([ + [false, false, 'direct'], + [false, true, 'direct'], + [true, true, 'changeRequest'], + [true, false, 'readOnly'], + ] as const)( + 'change requests %s, versioned %s: %s', + (requiresChangeRequests, isVersioned, expected) => { + expect(getDependenciesMode(requiresChangeRequests, isVersioned)).toBe( + expected, + ) + }, + ) +}) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/prerequisiteState.test.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/prerequisiteState.test.ts index 8b3d39b20a1c..3cf2f26a09ce 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/prerequisiteState.test.ts +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/prerequisiteState.test.ts @@ -4,6 +4,7 @@ import { isBlocked, toPrerequisiteRow, toPrerequisiteRows, + withStagedChanges, } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState' const edge = ( @@ -141,3 +142,41 @@ describe('describeOffCount', () => { ) }) }) + +describe('withStagedChanges', () => { + const live = [ + toPrerequisiteRow(edge(1), true), + toPrerequisiteRow(edge(2), false), + ] + const isEnabled = (id: number) => id === 3 + + it('marks a held removal on its live row', () => { + const rows = withStagedChanges( + live, + [{ action: 'remove', prerequisite: { id: 2, name: 'prereq_2' } }], + 1, + isEnabled, + ) + expect(rows.map((r) => r.staged)).toEqual([undefined, 'remove']) + }) + + it('appends a held add after the live rows, with its current state', () => { + const rows = withStagedChanges( + live, + [{ action: 'add', prerequisite: { id: 3, name: 'prereq_3' } }], + 1, + isEnabled, + ) + expect(rows).toHaveLength(3) + expect(rows[2]).toMatchObject({ + isEnabled: true, + isMet: true, + staged: 'add', + }) + expect(rows[2].edge.prerequisite).toEqual({ id: 3, name: 'prereq_3' }) + }) + + it('leaves the rows alone with nothing held', () => { + expect(withStagedChanges(live, [], 1, isEnabled)).toEqual(live) + }) +}) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts new file mode 100644 index 000000000000..e4f2306a28c1 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts @@ -0,0 +1,38 @@ +import { + describeApiError, + toStagingError, +} from 'components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError' + +describe('describeApiError', () => { + it.each([ + [{ data: { code: 'x', message: 'Refused.' } }, 'Refused.'], + [{ data: { detail: 'Not found.' } }, 'Not found.'], + [ + { data: { feature_states: ['This field is required.'] } }, + 'feature_states: This field is required.', + ], + [{ status: 500 }, undefined], + [undefined, undefined], + ])('%j reads as %j', (error, expected) => { + expect(describeApiError(error)).toBe(expected) + }) +}) + +describe('toStagingError', () => { + it('keeps a staging error, with the row it refers to', () => { + const error = { message: 'Refused.', prerequisiteId: 3 } + expect(toStagingError(error, 'Fallback')).toBe(error) + }) + + it('reads an API error', () => { + expect(toStagingError({ data: { detail: 'Nope.' } }, 'Fallback')).toEqual({ + message: 'Nope.', + }) + }) + + it('falls back when there is nothing to read', () => { + expect(toStagingError({ status: 500 }, 'Fallback')).toEqual({ + message: 'Fallback', + }) + }) +}) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/dependenciesMode.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/dependenciesMode.ts new file mode 100644 index 000000000000..822e49634d5a --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/dependenciesMode.ts @@ -0,0 +1,12 @@ +// How dependency changes reach an environment: written directly, held for a +// change request, or not at all where change requests run on v1 versioning, +// which #8449 does not support. +export type DependenciesMode = 'direct' | 'changeRequest' | 'readOnly' + +export const getDependenciesMode = ( + requiresChangeRequests: boolean, + isVersioned: boolean, +): DependenciesMode => { + if (!requiresChangeRequests) return 'direct' + return isVersioned ? 'changeRequest' : 'readOnly' +} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useDependencies.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useDependencies.ts index 618181fbcfd3..763603987930 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useDependencies.ts +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useDependencies.ts @@ -55,6 +55,7 @@ export const useDependencies = ({ }).unwrap(), dependentEdges: dependents?.results ?? [], isCreating, + isEnabled: featureStates.isEnabled, // The feature list counts too. Without it every prerequisite reads as off, // so the tab would claim the flag is serving off while the list is still // in flight, or for good if the request fails. diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/usePrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/usePrerequisites.tsx index 6901bd19bd98..081b129eb0a2 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/usePrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/usePrerequisites.tsx @@ -28,8 +28,16 @@ export const usePrerequisites = ({ const [addingName, setAddingName] = useState() const { flash, flashedId } = useRowFlash() - const { add, dependentEdges, isCreating, isError, isLoading, remove, rows } = - useDependencies({ environmentId, featureId: projectFlag.id, projectId }) + const { + add, + dependentEdges, + isCreating, + isEnabled, + isError, + isLoading, + remove, + rows, + } = useDependencies({ environmentId, featureId: projectFlag.id, projectId }) // A refusal belongs to the add it came from, so it ends with it. const onAddingChange = useCallback((adding: boolean) => { @@ -84,6 +92,7 @@ export const usePrerequisites = ({ dependentEdges, flashedId, isCreating, + isEnabled, isError, isLoading, // A flag that gains a dependent cannot take prerequisites, so the picker diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx new file mode 100644 index 000000000000..cb324c3d95a0 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react' +import { + DependencyEdge, + DependencyFeature, + ProjectFlag, +} from 'common/types/responses' +import { Req } from 'common/types/requests' +import { + useCreateEnvironmentChangeRequestMutation, + useDeleteChangeRequestMutation, +} from 'common/services/useChangeRequest' +import { + useCreateFeatureDependencyMutation, + useDeleteFeatureDependencyMutation, +} from 'common/services/useFeatureDependency' +import { useLazyGetFeatureStatesQuery } from 'common/services/useFeatureState' +import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' +import ChangeRequestModal from 'components/modals/ChangeRequestModal' +import { + StagingError, + describeApiError, + toStagingError, +} from 'components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError' +import { unchangedChangeSet } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet' + +export type StagedChange = { + action: 'add' | 'remove' + prerequisite: DependencyFeature +} + +type UseStagedPrerequisitesArgs = { + environmentId: string + projectId: number + projectFlag: ProjectFlag +} + +type ChangeRequestFields = Omit< + Req['createEnvironmentChangeRequest'], + 'environmentId' +> + +/** + * Where change requests are required, nothing is written until the change + * request exists. Changes are held here, then staged in it one by one. + */ +export const useStagedPrerequisites = ({ + environmentId, + projectFlag, + projectId, +}: UseStagedPrerequisitesArgs) => { + const [staged, setStaged] = useState([]) + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const [createChangeRequest] = useCreateEnvironmentChangeRequestMutation() + const [deleteChangeRequest] = useDeleteChangeRequestMutation() + const [createDependency] = useCreateFeatureDependencyMutation() + const [deleteDependency] = useDeleteFeatureDependencyMutation() + const [getFeatureStates] = useLazyGetFeatureStatesQuery() + const { getEnvironmentIdFromKey } = useProjectEnvironments(projectId) + + const drop = (prerequisiteId: number) => + setStaged((prev) => + prev.filter((change) => change.prerequisite.id !== prerequisiteId), + ) + + const add = (feature: ProjectFlag) => { + setError(null) + setStaged((prev) => [ + ...prev, + { action: 'add', prerequisite: { id: feature.id, name: feature.name } }, + ]) + } + + // A held add is dropped rather than held as both. + const remove = (edge: DependencyEdge) => { + setError(null) + if ( + staged.some((change) => change.prerequisite.id === edge.prerequisite.id) + ) + return drop(edge.prerequisite.id) + setStaged((prev) => [ + ...prev, + { action: 'remove', prerequisite: edge.prerequisite }, + ]) + } + + const stage = async (changeRequestId: number) => { + // In order, so the first refusal names the change that caused it. + for (const { action, prerequisite } of staged) { + const query = { + changeRequestId, + environmentId, + featureId: projectFlag.id, + prerequisiteFeatureId: prerequisite.id, + prerequisiteName: prerequisite.name, + } + try { + await (action === 'add' + ? createDependency(query).unwrap() + : deleteDependency(query).unwrap()) + } catch (e) { + const error: StagingError = { + message: + describeApiError(e) ?? `Could not stage ${prerequisite.name}.`, + prerequisiteId: prerequisite.id, + } + throw error + } + } + } + + const submitChangeRequest = async (fields: ChangeRequestFields) => { + setIsSubmitting(true) + setError(null) + let changeRequestId: number | undefined + try { + const featureStates = await getFeatureStates({ + environment: getEnvironmentIdFromKey(environmentId), + feature: projectFlag.id, + }).unwrap() + const changeSet = unchangedChangeSet( + projectFlag.id, + featureStates.results, + fields.live_from, + ) + changeRequestId = ( + await createChangeRequest({ + environmentId, + ...fields, + change_sets: changeSet ? [changeSet] : undefined, + }).unwrap() + ).id + await stage(changeRequestId) + setStaged([]) + toast( + <> + Change request created.{' '} + + View it + + , + ) + } catch (e) { + // A change request missing some of the changes is worse than none. + if (changeRequestId) await deleteChangeRequest({ id: changeRequestId }) + setError(toStagingError(e, 'Could not create the change request.')) + } finally { + setIsSubmitting(false) + } + } + + const openChangeRequest = () => + openModal2( + 'New Change Request', + { + closeModal2() + submitChangeRequest({ approvals, description, live_from, title }) + }} + />, + ) + + return { + add, + discard: () => { + setError(null) + setStaged([]) + }, + error, + isSubmitting, + openChangeRequest, + remove, + staged, + undo: drop, + } +} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts index 4fe5774d6fcc..cbd6d16dd91e 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts @@ -1,12 +1,21 @@ -import { DependencyEdge, ProjectFlag } from 'common/types/responses' +import { + DependencyEdge, + DependencyFeature, + ProjectFlag, +} from 'common/types/responses' +import { stagedDependencyEdge } from 'common/utils/stagedDependencyEdge' export type PrerequisiteRow = { edge: DependencyEdge isEnabled: boolean // Undefined where the rule cannot be read. See toPrerequisiteRow. isMet?: boolean + // Held for a change request, not live yet. + staged?: StagedAction } +export type StagedAction = 'add' | 'remove' + // A system edge always carries the hardcoded `enabled != true` condition from // _get_or_create_dependency_segment in api/features/dependencies/services.py, // so on means met. A hand-written segment referencing a flag is listed here @@ -35,6 +44,32 @@ export const toPrerequisiteRows = ( ), ) +// Live rows with their held removals marked, then the held adds. +export const withStagedChanges = ( + rows: PrerequisiteRow[], + staged: { action: StagedAction; prerequisite: DependencyFeature }[], + featureId: number, + isEnabled: (prerequisiteId: number) => boolean, +): PrerequisiteRow[] => [ + ...rows.map((row) => + staged.some( + (c) => + c.action === 'remove' && c.prerequisite.id === row.edge.prerequisite.id, + ) + ? { ...row, staged: 'remove' as const } + : row, + ), + ...staged + .filter((c) => c.action === 'add') + .map(({ prerequisite }) => ({ + ...toPrerequisiteRow( + stagedDependencyEdge(featureId, prerequisite), + isEnabled(prerequisite.id), + ), + staged: 'add' as const, + })), +] + export const isBlocked = (rows: PrerequisiteRow[]): boolean => rows.some((row) => row.isMet === false) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts new file mode 100644 index 000000000000..4e3d3fce57b3 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts @@ -0,0 +1,21 @@ +export type StagingError = { message: string; prerequisiteId?: number } + +const isStagingError = (e: unknown): e is StagingError => + typeof e === 'object' && + e !== null && + typeof (e as StagingError).message === 'string' + +// The API answers with { message }, { detail } or { field: [messages] }. +export const describeApiError = (e: unknown): string | undefined => { + const data = typeof e === 'object' && e !== null && 'data' in e && e.data + if (!data || typeof data !== 'object') return + if ('message' in data && typeof data.message === 'string') return data.message + if ('detail' in data && typeof data.detail === 'string') return data.detail + const [field, messages] = Object.entries(data)[0] ?? [] + return field + ? `${field}: ${([] as unknown[]).concat(messages).join(' ')}` + : undefined +} + +export const toStagingError = (e: unknown, fallback: string): StagingError => + isStagingError(e) ? e : { message: describeApiError(e) ?? fallback } diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts new file mode 100644 index 000000000000..51b1ff6b4079 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts @@ -0,0 +1,37 @@ +import { FeatureState } from 'common/types/responses' +import { Req } from 'common/types/requests' + +type ChangeSetBody = NonNullable< + Req['createEnvironmentChangeRequest']['change_sets'] +>[number] + +// Until #8449, the API refuses a change request with no changes, so it +// carries the feature's current value unchanged. If the value changes before +// this is published, publishing puts the old one back; the page flags it as a +// conflict. +export const unchangedChangeSet = ( + featureId: number, + featureStates: FeatureState[], + liveFrom: string | undefined, +): ChangeSetBody | undefined => { + const current = featureStates.find( + (state) => !state.feature_segment && !state.identity, + ) + if (!current) return + const { id: _id, multivariate_feature_state_values, ...state } = current + return { + feature: featureId, + feature_states_to_create: [], + feature_states_to_update: [ + { + ...state, + multivariate_feature_state_values: + multivariate_feature_state_values?.map( + ({ id: _valueId, ...value }) => value, + ), + }, + ], + live_from: liveFrom ?? new Date().toISOString(), + segment_ids_to_delete_overrides: [], + } +} From 07dacfdb3d95eb51136a8025266f560dcc7f6594 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 12 Oct 2026 19:47:07 -0300 Subject: [PATCH 02/22] refactor(dependencies): create the change request empty The API accepts a change request with no changes, so it no longer carries the feature's current value, which publishing could put back over a newer one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../hooks/useStagedPrerequisites.tsx | 20 +--------- .../unchangedChangeSet.ts | 37 ------------------- 2 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index cb324c3d95a0..8f45c91000cd 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -13,15 +13,12 @@ import { useCreateFeatureDependencyMutation, useDeleteFeatureDependencyMutation, } from 'common/services/useFeatureDependency' -import { useLazyGetFeatureStatesQuery } from 'common/services/useFeatureState' -import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' import ChangeRequestModal from 'components/modals/ChangeRequestModal' import { StagingError, describeApiError, toStagingError, } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError' -import { unchangedChangeSet } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet' export type StagedChange = { action: 'add' | 'remove' @@ -56,8 +53,6 @@ export const useStagedPrerequisites = ({ const [deleteChangeRequest] = useDeleteChangeRequestMutation() const [createDependency] = useCreateFeatureDependencyMutation() const [deleteDependency] = useDeleteFeatureDependencyMutation() - const [getFeatureStates] = useLazyGetFeatureStatesQuery() - const { getEnvironmentIdFromKey } = useProjectEnvironments(projectId) const drop = (prerequisiteId: number) => setStaged((prev) => @@ -115,21 +110,8 @@ export const useStagedPrerequisites = ({ setError(null) let changeRequestId: number | undefined try { - const featureStates = await getFeatureStates({ - environment: getEnvironmentIdFromKey(environmentId), - feature: projectFlag.id, - }).unwrap() - const changeSet = unchangedChangeSet( - projectFlag.id, - featureStates.results, - fields.live_from, - ) changeRequestId = ( - await createChangeRequest({ - environmentId, - ...fields, - change_sets: changeSet ? [changeSet] : undefined, - }).unwrap() + await createChangeRequest({ environmentId, ...fields }).unwrap() ).id await stage(changeRequestId) setStaged([]) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts deleted file mode 100644 index 51b1ff6b4079..000000000000 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/unchangedChangeSet.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { FeatureState } from 'common/types/responses' -import { Req } from 'common/types/requests' - -type ChangeSetBody = NonNullable< - Req['createEnvironmentChangeRequest']['change_sets'] ->[number] - -// Until #8449, the API refuses a change request with no changes, so it -// carries the feature's current value unchanged. If the value changes before -// this is published, publishing puts the old one back; the page flags it as a -// conflict. -export const unchangedChangeSet = ( - featureId: number, - featureStates: FeatureState[], - liveFrom: string | undefined, -): ChangeSetBody | undefined => { - const current = featureStates.find( - (state) => !state.feature_segment && !state.identity, - ) - if (!current) return - const { id: _id, multivariate_feature_state_values, ...state } = current - return { - feature: featureId, - feature_states_to_create: [], - feature_states_to_update: [ - { - ...state, - multivariate_feature_state_values: - multivariate_feature_state_values?.map( - ({ id: _valueId, ...value }) => value, - ), - }, - ], - live_from: liveFrom ?? new Date().toISOString(), - segment_ids_to_delete_overrides: [], - } -} From d230203507f4532156dc02bb28b1272f49f57db4 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 08:53:46 -0300 Subject: [PATCH 03/22] fix(dependencies): hold the dependency changes while the change request is created Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx index 6f729edd2bef..ece426fc476c 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx @@ -50,12 +50,13 @@ const FeatureDependenciesTab: FC = ({ ? EnvironmentPermission.CREATE_CHANGE_REQUEST : EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, }) - const canManage = permission && mode !== 'readOnly' const staging = useStagedPrerequisites({ environmentId, projectFlag, projectId, }) + // Held while submitting, so a change can't miss the change request. + const canManage = permission && mode !== 'readOnly' && !staging.isSubmitting const { addingName, @@ -138,7 +139,7 @@ const FeatureDependenciesTab: FC = ({ existingPrerequisiteIds={shownRows.map( (row) => row.edge.prerequisite.id, )} - disabled={isCreating} + disabled={isCreating || staging.isSubmitting} onChange={handleAdd} /> ) From 01fa7f9fd33f5af672cbd1d40f17982c322e83cb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 08:53:46 -0300 Subject: [PATCH 04/22] fix(dependencies): say when an incomplete change request could not be deleted Co-Authored-By: Claude Opus 5.5 (1M context) --- .../hooks/useStagedPrerequisites.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index 8f45c91000cd..9c5fef561971 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -126,9 +126,18 @@ export const useStagedPrerequisites = ({ , ) } catch (e) { + const error = toStagingError(e, 'Could not create the change request.') + if (!changeRequestId) return setError(error) // A change request missing some of the changes is worse than none. - if (changeRequestId) await deleteChangeRequest({ id: changeRequestId }) - setError(toStagingError(e, 'Could not create the change request.')) + try { + await deleteChangeRequest({ id: changeRequestId }).unwrap() + setError(error) + } catch { + setError({ + ...error, + message: `${error.message} Change request #${changeRequestId} is incomplete and could not be deleted, so delete it from the change requests page.`, + }) + } } finally { setIsSubmitting(false) } From cde95d6b88d02e6a280cd1ab965fe867d7238d43 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 16:35:34 -0300 Subject: [PATCH 05/22] refactor(dependencies): stop sending the prerequisite name when staging Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index 9c5fef561971..bfc0c7b9e99b 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -88,7 +88,6 @@ export const useStagedPrerequisites = ({ environmentId, featureId: projectFlag.id, prerequisiteFeatureId: prerequisite.id, - prerequisiteName: prerequisite.name, } try { await (action === 'add' From d8e9618e6a996b4304204c7a09225fc33d706ce7 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 16:40:40 -0300 Subject: [PATCH 06/22] refactor(dependencies): add the stand-in edge where it is first used Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/common/utils/stagedDependencyEdge.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 frontend/common/utils/stagedDependencyEdge.ts diff --git a/frontend/common/utils/stagedDependencyEdge.ts b/frontend/common/utils/stagedDependencyEdge.ts new file mode 100644 index 000000000000..fc4de2a33cce --- /dev/null +++ b/frontend/common/utils/stagedDependencyEdge.ts @@ -0,0 +1,18 @@ +import { DependencyEdge, DependencyFeature } from 'common/types/responses' + +// A prerequisite held for a change request has no segment until the change +// request is published, so it gets a stand-in. +export const stagedDependencyEdge = ( + featureId: number, + prerequisite: DependencyFeature, +): DependencyEdge => ({ + feature: { id: featureId, name: '' }, + prerequisite, + segment: { + condition_json_path: '', + id: -prerequisite.id, + is_system: true, + name: '', + rules: [], + }, +}) From c3b5a74fce5219cc3f0fdfb5bea97b290fa58147 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 16:47:26 -0300 Subject: [PATCH 07/22] refactor(dependencies): pick held or written edits in one hook One type for a staged change, and the tab only wires what the hook returns. The error title holds whether or not the change request was left behind. Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/common/types/responses.ts | 8 +++ .../modals/create-feature/index.tsx | 4 +- .../FeatureDependenciesTab.tsx | 47 +++++----------- .../hooks/usePrerequisiteEditing.ts | 53 +++++++++++++++++++ .../hooks/useStagedPrerequisites.tsx | 14 ++--- .../prerequisiteState.ts | 7 ++- 6 files changed, 83 insertions(+), 50 deletions(-) create mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/usePrerequisiteEditing.ts diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index eeaf6a761c34..9c6096351ed8 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -185,6 +185,14 @@ export type DependencyEdge = { segment: DependencyEdgeSegment } +export type StagedAction = 'add' | 'remove' + +// A dependency held in a change request, not live until it is published. +export type StagedDependencyChange = { + action: StagedAction + prerequisite: DependencyFeature +} + export type DependencyConflictCode = | 'circular_dependency' | 'dependency_exists' diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 53d77414e0c3..193bb6cd6a47 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -460,8 +460,8 @@ const CreateFeatureModal: FC = (props) => { controlValue < 0 const isVersionedChangeRequest = existingChangeRequest && isVersioned const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() - // Where change requests are enforced, the tab waits on its own flag while the - // API for staging dependencies (#8449) is faked. + // Where change requests are enforced, the tab waits on its own flag until the + // API can stage dependencies (#8449). const isDependenciesTabEnabled = Utils.getFlagsmithHasFeature('flag_dependencies') && !existingChangeRequest && diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx index ece426fc476c..e31c0ae73a05 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx @@ -17,9 +17,7 @@ import ChangeRequestNotice from './ChangeRequestNotice' import DependenciesHeader from './DependenciesHeader' import PrerequisitePicker from './PrerequisitePicker' import { getDependenciesMode } from './dependenciesMode' -import { withStagedChanges } from './prerequisiteState' -import { usePrerequisites } from './hooks/usePrerequisites' -import { useStagedPrerequisites } from './hooks/useStagedPrerequisites' +import { usePrerequisiteEditing } from './hooks/usePrerequisiteEditing' type FeatureDependenciesTabProps = { environmentId: string @@ -50,36 +48,29 @@ const FeatureDependenciesTab: FC = ({ ? EnvironmentPermission.CREATE_CHANGE_REQUEST : EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, }) - const staging = useStagedPrerequisites({ + const { editing, prerequisites, staging } = usePrerequisiteEditing({ environmentId, + environmentName, + isChangeRequest, projectFlag, projectId, }) - // Held while submitting, so a change can't miss the change request. - const canManage = permission && mode !== 'readOnly' && !staging.isSubmitting - const { addingName, conflict, dependentEdges, flashedId, isCreating, - isEnabled, isError, isLoading, isOpenForAdding, isPrerequisite, - onAdd, onAddingChange, - onRemove, removingId, rows, - } = usePrerequisites({ - environmentId, - environmentName, - projectFlag, - projectId, - }) + } = prerequisites + // Held while submitting, so a change can't miss the change request. + const canManage = permission && mode !== 'readOnly' && !staging.isSubmitting // Without the permission, canManage is false, so the add button and every // bin would appear a moment after the rows. @@ -92,16 +83,6 @@ const FeatureDependenciesTab: FC = ({ return } - const shownRows = isChangeRequest - ? withStagedChanges(rows, staging.staged, projectFlag.id, isEnabled) - : rows - const handleAdd = isChangeRequest - ? (feature: ProjectFlag) => { - staging.add(feature) - onAddingChange(false) - } - : onAdd - return (
@@ -116,18 +97,18 @@ const FeatureDependenciesTab: FC = ({ {/* addingName too: on a first add the picker has closed and no row has arrived yet, and the empty state would take the pending row's place. */} - {shownRows.length || isOpenForAdding || addingName ? ( + {editing.rows.length || isOpenForAdding || addingName ? ( <> staging.undo(edge.prerequisite.id)} onSelect={(edge) => onSelectFeature(edge.prerequisite.id)} addControl={ @@ -136,11 +117,11 @@ const FeatureDependenciesTab: FC = ({ projectId={projectId} environmentId={environmentId} featureId={projectFlag.id} - existingPrerequisiteIds={shownRows.map( + existingPrerequisiteIds={editing.rows.map( (row) => row.edge.prerequisite.id, )} disabled={isCreating || staging.isSubmitting} - onChange={handleAdd} + onChange={editing.onAdd} /> ) } @@ -169,7 +150,7 @@ const FeatureDependenciesTab: FC = ({ {staging.error.message} @@ -186,7 +167,7 @@ const FeatureDependenciesTab: FC = ({ )} - {isChangeRequest && !!staging.staged.length && ( + {!!staging.staged.length && ( { + const prerequisites = usePrerequisites({ + environmentId, + environmentName, + projectFlag, + projectId, + }) + const staging = useStagedPrerequisites({ + environmentId, + projectFlag, + projectId, + }) + + const { isEnabled, onAdd, onAddingChange, onRemove, rows } = prerequisites + const editing = isChangeRequest + ? { + onAdd: (feature: ProjectFlag) => { + staging.add(feature) + onAddingChange(false) + }, + onRemove: staging.remove, + rows: withStagedChanges( + rows, + staging.staged, + projectFlag.id, + isEnabled, + ), + } + : { onAdd, onRemove, rows } + + return { editing, prerequisites, staging } +} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index bfc0c7b9e99b..46af9baccc2b 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { DependencyEdge, - DependencyFeature, ProjectFlag, + StagedDependencyChange, } from 'common/types/responses' import { Req } from 'common/types/requests' import { @@ -20,11 +20,6 @@ import { toStagingError, } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError' -export type StagedChange = { - action: 'add' | 'remove' - prerequisite: DependencyFeature -} - type UseStagedPrerequisitesArgs = { environmentId: string projectId: number @@ -36,16 +31,13 @@ type ChangeRequestFields = Omit< 'environmentId' > -/** - * Where change requests are required, nothing is written until the change - * request exists. Changes are held here, then staged in it one by one. - */ +// Holds dependency changes until the change request exists, then stages them. export const useStagedPrerequisites = ({ environmentId, projectFlag, projectId, }: UseStagedPrerequisitesArgs) => { - const [staged, setStaged] = useState([]) + const [staged, setStaged] = useState([]) const [error, setError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts index cbd6d16dd91e..ae4bb215222e 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState.ts @@ -1,7 +1,8 @@ import { DependencyEdge, - DependencyFeature, ProjectFlag, + StagedAction, + StagedDependencyChange, } from 'common/types/responses' import { stagedDependencyEdge } from 'common/utils/stagedDependencyEdge' @@ -14,8 +15,6 @@ export type PrerequisiteRow = { staged?: StagedAction } -export type StagedAction = 'add' | 'remove' - // A system edge always carries the hardcoded `enabled != true` condition from // _get_or_create_dependency_segment in api/features/dependencies/services.py, // so on means met. A hand-written segment referencing a flag is listed here @@ -47,7 +46,7 @@ export const toPrerequisiteRows = ( // Live rows with their held removals marked, then the held adds. export const withStagedChanges = ( rows: PrerequisiteRow[], - staged: { action: StagedAction; prerequisite: DependencyFeature }[], + staged: StagedDependencyChange[], featureId: number, isEnabled: (prerequisiteId: number) => boolean, ): PrerequisiteRow[] => [ From ca2600ec9962c9fa817881fd13d1e8b3c4e9de22 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 16:56:08 -0300 Subject: [PATCH 08/22] refactor(dependencies): make the staging error a real error Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/stagingError.test.ts | 13 ++++++------ .../hooks/useStagedPrerequisites.tsx | 20 +++++++++---------- .../FeatureDependenciesTab/stagingError.ts | 17 ++++++++++------ 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts index e4f2306a28c1..29ea0f3e0575 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/__tests__/stagingError.test.ts @@ -1,4 +1,5 @@ import { + StagingError, describeApiError, toStagingError, } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError' @@ -20,19 +21,17 @@ describe('describeApiError', () => { describe('toStagingError', () => { it('keeps a staging error, with the row it refers to', () => { - const error = { message: 'Refused.', prerequisiteId: 3 } + const error = new StagingError('Refused.', 3) expect(toStagingError(error, 'Fallback')).toBe(error) }) it('reads an API error', () => { - expect(toStagingError({ data: { detail: 'Nope.' } }, 'Fallback')).toEqual({ - message: 'Nope.', - }) + expect( + toStagingError({ data: { detail: 'Nope.' } }, 'Fallback').message, + ).toBe('Nope.') }) it('falls back when there is nothing to read', () => { - expect(toStagingError({ status: 500 }, 'Fallback')).toEqual({ - message: 'Fallback', - }) + expect(toStagingError({ status: 500 }, 'Fallback').message).toBe('Fallback') }) }) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index 46af9baccc2b..60a0e6c45fba 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -86,12 +86,10 @@ export const useStagedPrerequisites = ({ ? createDependency(query).unwrap() : deleteDependency(query).unwrap()) } catch (e) { - const error: StagingError = { - message: - describeApiError(e) ?? `Could not stage ${prerequisite.name}.`, - prerequisiteId: prerequisite.id, - } - throw error + throw new StagingError( + describeApiError(e) ?? `Could not stage ${prerequisite.name}.`, + prerequisite.id, + ) } } } @@ -124,10 +122,12 @@ export const useStagedPrerequisites = ({ await deleteChangeRequest({ id: changeRequestId }).unwrap() setError(error) } catch { - setError({ - ...error, - message: `${error.message} Change request #${changeRequestId} is incomplete and could not be deleted, so delete it from the change requests page.`, - }) + setError( + new StagingError( + `${error.message} Change request #${changeRequestId} is incomplete and could not be deleted, so delete it from the change requests page.`, + error.prerequisiteId, + ), + ) } } finally { setIsSubmitting(false) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts index 4e3d3fce57b3..83b8dc992409 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/stagingError.ts @@ -1,9 +1,12 @@ -export type StagingError = { message: string; prerequisiteId?: number } +// Why staging stopped, with the row it refers to when there is one. +export class StagingError extends Error { + prerequisiteId?: number -const isStagingError = (e: unknown): e is StagingError => - typeof e === 'object' && - e !== null && - typeof (e as StagingError).message === 'string' + constructor(message: string, prerequisiteId?: number) { + super(message) + this.prerequisiteId = prerequisiteId + } +} // The API answers with { message }, { detail } or { field: [messages] }. export const describeApiError = (e: unknown): string | undefined => { @@ -18,4 +21,6 @@ export const describeApiError = (e: unknown): string | undefined => { } export const toStagingError = (e: unknown, fallback: string): StagingError => - isStagingError(e) ? e : { message: describeApiError(e) ?? fallback } + e instanceof StagingError + ? e + : new StagingError(describeApiError(e) ?? fallback) From 12a6f3b8b629cfb785e6d9456182386d000d3684 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 13 Oct 2026 16:57:45 -0300 Subject: [PATCH 09/22] refactor(dependencies): let utilities shrink the prerequisite name Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PrerequisitesTable/PrerequisitesTable.scss | 9 --------- .../PrerequisitesTable/PrerequisitesTable.tsx | 6 +++--- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.scss b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.scss index 33fa89e36fe1..54d52a48d425 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.scss +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.scss @@ -15,15 +15,6 @@ background: var(--color-surface-muted); } -// A button only shrinks below its text with a min width. -.prerequisite-name > .btn { - min-width: 0; -} - -.prerequisite-name__text { - min-width: 0; -} - // On the cell, as with the input row, so hover does not replace it. .prerequisite-row--refused td { background: var(--color-surface-danger); diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx index ee1da64bab0f..08475d434dd8 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx @@ -80,17 +80,17 @@ const PrerequisitesTable: FC = ({ {/* The name gives way to the chip, so a long name cannot hide what is about to happen to it. */} -
+
{!!staged && ( - - {staged === 'add' ? 'Staged: add' : 'Staged: remove'} - + )}
diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx new file mode 100644 index 000000000000..adb0f4ec7152 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react' +import { StagedAction } from 'common/types/responses' +import Chip, { ChipVariant } from 'components/base/Chip' + +type StagedChipProps = { + action: StagedAction + isRefused?: boolean +} + +const variant = (action: StagedAction, isRefused?: boolean): ChipVariant => { + if (isRefused) return 'danger' + return action === 'add' ? 'accent' : 'neutral' +} + +const StagedChip: FC = ({ action, isRefused }) => ( + + {isRefused ? 'Rejected' : 'Staged'}: {action} + +) + +export default StagedChip From 51b200e81dea5597002cdbc2828be96cfe0b033b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 21 Oct 2026 00:27:59 +0100 Subject: [PATCH 20/22] fix(Dependencies): Show the change request toast only once its dependencies are staged Co-Authored-By: Claude Opus 5.5 (1M context) --- .../create-feature/hoc/FeatureProvider.tsx | 81 ++++++++++++++----- .../modals/create-feature/index.tsx | 18 ++++- .../hooks/useStagedPrerequisites.tsx | 1 + 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx index 254fec52a67f..8411ad1335c7 100644 --- a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx +++ b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx @@ -3,6 +3,13 @@ import FeatureListStore from 'common/stores/feature-list-store' import ES6Component from 'common/ES6Component' import { setModalTitle } from 'components/modals/base/ModalDefault' +type SavedToast = { + changeRequest?: boolean + createdFlag?: string + isCreate?: boolean + updatedChangeRequest?: { id: number } +} + // TODO: Migrate to a custom hook once we move away from Flux stores. // This class component is necessary because it uses ES6Component/listenTo // to subscribe to FeatureListStore events, which requires class lifecycle methods. @@ -40,27 +47,17 @@ export default function withFeatureProvider( 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 = isEditingChangeRequest - ? `Updated ${type}` - : `${operation} ${type}` - const toastAction = changeRequest - ? { - buttonText: 'Open', - onClick: () => { - closeModal() - this.props.history.push( - `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, - ) - }, - } - : undefined - - toast(toastText, 'success', undefined, toastAction) + const saved = { + changeRequest, + createdFlag, + isCreate, + updatedChangeRequest, + } + if (this.heldToast) { + this.heldToast.saved = saved + } else { + this.showSavedToast(saved) + } } const envFlags = FeatureListStore.getEnvironmentFlags() @@ -107,11 +104,53 @@ export default function withFeatureProvider( listenTo: any + // Held while the modal stages dependency changes into the change request, + // so a partial save does not show as a success. + heldToast: { saved?: SavedToast } | null = null + + holdSavedToast = () => { + const held: { saved?: SavedToast } = {} + this.heldToast = held + return (show: boolean) => { + if (this.heldToast === held) this.heldToast = null + if (show && held.saved) this.showSavedToast(held.saved) + } + } + + showSavedToast = ({ + changeRequest, + createdFlag, + isCreate, + updatedChangeRequest, + }: SavedToast) => { + const isEditingChangeRequest = this.props.changeRequest && changeRequest + const operation = createdFlag || isCreate ? 'Created' : 'Updated' + const type = changeRequest ? 'Change Request' : 'Feature' + + const toastText = isEditingChangeRequest + ? `Updated ${type}` + : `${operation} ${type}` + const toastAction = changeRequest + ? { + buttonText: 'Open', + onClick: () => { + closeModal() + this.props.history.push( + `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, + ) + }, + } + : undefined + + toast(toastText, 'success', undefined, toastAction) + } + render() { return ( ) } diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 49a38bba3576..1d14cdff5f89 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -74,6 +74,8 @@ type CreateFeatureModalProps = { highlightSegmentId?: number history?: History multivariate_options?: MultivariateFeatureStateValue[] + // From FeatureProvider: holds its save toast until the returned call. + holdSavedToast: () => (show: boolean) => void } & Partial type InjectedSegmentOverrideProps = { @@ -301,6 +303,9 @@ const CreateFeatureModal: FC = (props) => { any[] | null >(null) const mvBaselineRefreshRef = useRef(false) + const releaseSavedToastRef = useRef<((show: boolean) => void) | undefined>( + undefined, + ) const originalMultivariateOptions = useMemo(() => { if (savedMultivariateOptions) { return savedMultivariateOptions @@ -501,6 +506,8 @@ const CreateFeatureModal: FC = (props) => { return ( { + releaseSavedToastRef.current?.(false) + releaseSavedToastRef.current = undefined if (mvBaselineRefreshRef.current) { // The value save failed — keep the unsaved indicators accurate. mvBaselineRefreshRef.current = false @@ -509,8 +516,14 @@ const CreateFeatureModal: FC = (props) => { }} onSave={(data?: { updatedChangeRequest?: { id: number } }) => { const changeRequestId = data?.updatedChangeRequest?.id + const releaseSavedToast = releaseSavedToastRef.current + releaseSavedToastRef.current = undefined if (changeRequestId && dependenciesChanged) { - dependencyChanges.stageInto(changeRequestId) + dependencyChanges + .stageInto(changeRequestId) + .then((failure) => releaseSavedToast?.(!failure)) + } else { + releaseSavedToast?.(true) } if (identity) { close() @@ -699,6 +712,9 @@ const CreateFeatureModal: FC = (props) => { title, }: any) => { closeModal2() + if (dependenciesChanged) { + releaseSavedToastRef.current = props.holdSavedToast() + } save( ( _projectId: any, diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index f2f4b48a452f..a1e2fdd7b942 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -104,6 +104,7 @@ export const useStagedPrerequisites = ({ setStaged([]) } setIsSubmitting(false) + return failure } return { From f149753bafba199eb67850cf79f04e40d717285b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 21 Oct 2026 00:32:25 +0100 Subject: [PATCH 21/22] refactor(Dependencies): Toast the change request from the modal instead of holding the provider's toast Co-Authored-By: Claude Opus 5.5 (1M context) --- .../create-feature/hoc/FeatureProvider.tsx | 73 ++----------------- .../modals/create-feature/index.tsx | 52 ++++++++----- 2 files changed, 37 insertions(+), 88 deletions(-) diff --git a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx index 8411ad1335c7..790384afbcac 100644 --- a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx +++ b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx @@ -3,13 +3,6 @@ import FeatureListStore from 'common/stores/feature-list-store' import ES6Component from 'common/ES6Component' import { setModalTitle } from 'components/modals/base/ModalDefault' -type SavedToast = { - changeRequest?: boolean - createdFlag?: string - isCreate?: boolean - updatedChangeRequest?: { id: number } -} - // TODO: Migrate to a custom hook once we move away from Flux stores. // This class component is necessary because it uses ES6Component/listenTo // to subscribe to FeatureListStore events, which requires class lifecycle methods. @@ -30,13 +23,7 @@ export default function withFeatureProvider( this.listenTo( FeatureListStore, 'saved', - ({ - changeRequest, - createdFlag, - error, - isCreate, - updatedChangeRequest, - }: any = {}) => { + ({ changeRequest, createdFlag, error, isCreate }: any = {}) => { if (error?.data?.metadata) { error.data.metadata?.forEach((m: any) => { if (Object.keys(m).length > 0) { @@ -46,18 +33,10 @@ export default function withFeatureProvider( } else if (error?.data) { toast('Error updating the Flag', 'danger') return - } else { - const saved = { - changeRequest, - createdFlag, - isCreate, - updatedChangeRequest, - } - if (this.heldToast) { - this.heldToast.saved = saved - } else { - this.showSavedToast(saved) - } + } else if (!changeRequest) { + // The modal toasts a change request once its dependencies are staged. + const operation = createdFlag || isCreate ? 'Created' : 'Updated' + toast(`${operation} Feature`, 'success') } const envFlags = FeatureListStore.getEnvironmentFlags() @@ -104,53 +83,11 @@ export default function withFeatureProvider( listenTo: any - // Held while the modal stages dependency changes into the change request, - // so a partial save does not show as a success. - heldToast: { saved?: SavedToast } | null = null - - holdSavedToast = () => { - const held: { saved?: SavedToast } = {} - this.heldToast = held - return (show: boolean) => { - if (this.heldToast === held) this.heldToast = null - if (show && held.saved) this.showSavedToast(held.saved) - } - } - - showSavedToast = ({ - changeRequest, - createdFlag, - isCreate, - updatedChangeRequest, - }: SavedToast) => { - const isEditingChangeRequest = this.props.changeRequest && changeRequest - const operation = createdFlag || isCreate ? 'Created' : 'Updated' - const type = changeRequest ? 'Change Request' : 'Feature' - - const toastText = isEditingChangeRequest - ? `Updated ${type}` - : `${operation} ${type}` - const toastAction = changeRequest - ? { - buttonText: 'Open', - onClick: () => { - closeModal() - this.props.history.push( - `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, - ) - }, - } - : undefined - - toast(toastText, 'success', undefined, toastAction) - } - render() { return ( ) } diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 1d14cdff5f89..e7ab3f699719 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -74,8 +74,6 @@ type CreateFeatureModalProps = { highlightSegmentId?: number history?: History multivariate_options?: MultivariateFeatureStateValue[] - // From FeatureProvider: holds its save toast until the returned call. - holdSavedToast: () => (show: boolean) => void } & Partial type InjectedSegmentOverrideProps = { @@ -303,9 +301,6 @@ const CreateFeatureModal: FC = (props) => { any[] | null >(null) const mvBaselineRefreshRef = useRef(false) - const releaseSavedToastRef = useRef<((show: boolean) => void) | undefined>( - undefined, - ) const originalMultivariateOptions = useMemo(() => { if (savedMultivariateOptions) { return savedMultivariateOptions @@ -501,29 +496,49 @@ const CreateFeatureModal: FC = (props) => { } const Provider = identity ? IdentityProvider : FeatureListProvider + + const toastChangeRequestSaved = (changeRequestId: number) => + toast( + `${existingChangeRequest ? 'Updated' : 'Created'} Change Request`, + 'success', + undefined, + { + buttonText: 'Open', + onClick: () => { + closeModal() + props.history?.push( + `/project/${projectId}/environment/${environmentId}/change-requests/${changeRequestId}`, + ) + }, + }, + ) const environmentName = environment.name return ( { - releaseSavedToastRef.current?.(false) - releaseSavedToastRef.current = undefined if (mvBaselineRefreshRef.current) { // The value save failed — keep the unsaved indicators accurate. mvBaselineRefreshRef.current = false setValueChanged(true) } }} - onSave={(data?: { updatedChangeRequest?: { id: number } }) => { - const changeRequestId = data?.updatedChangeRequest?.id - const releaseSavedToast = releaseSavedToastRef.current - releaseSavedToastRef.current = undefined - if (changeRequestId && dependenciesChanged) { - dependencyChanges - .stageInto(changeRequestId) - .then((failure) => releaseSavedToast?.(!failure)) - } else { - releaseSavedToast?.(true) + onSave={(data?: { + changeRequest?: boolean + updatedChangeRequest?: { id: number } + }) => { + const changeRequestId = data?.changeRequest + ? data.updatedChangeRequest?.id + : undefined + if (changeRequestId) { + const staging = dependenciesChanged + ? dependencyChanges.stageInto(changeRequestId) + : Promise.resolve(null) + // A change request missing some dependency changes gets the banner, + // not a success toast. + staging.then((failure) => { + if (!failure) toastChangeRequestSaved(changeRequestId) + }) } if (identity) { close() @@ -712,9 +727,6 @@ const CreateFeatureModal: FC = (props) => { title, }: any) => { closeModal2() - if (dependenciesChanged) { - releaseSavedToastRef.current = props.holdSavedToast() - } save( ( _projectId: any, From b5d3ce92729daaa5025da00d099faaf8148ad18d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 21 Oct 2026 09:37:21 +0100 Subject: [PATCH 22/22] fix(Dependencies): Clear the held changes once the change request exists A partial failure kept the rest held, so the next save put them in a second change request. The banner now carries the failure on its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../documentation/components/Chip.stories.tsx | 4 --- frontend/web/components/base/Chip/Chip.scss | 4 --- frontend/web/components/base/Chip/Chip.tsx | 4 +-- .../FeatureDependenciesTab.tsx | 1 - .../PrerequisitesTable/PrerequisitesTable.tsx | 16 ++++++------ .../PrerequisitesTable/StagedChip.tsx | 25 ------------------- .../hooks/useStagedPrerequisites.tsx | 14 +++-------- 7 files changed, 13 insertions(+), 55 deletions(-) delete mode 100644 frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx diff --git a/frontend/documentation/components/Chip.stories.tsx b/frontend/documentation/components/Chip.stories.tsx index 068a95042d1b..c88fe9860935 100644 --- a/frontend/documentation/components/Chip.stories.tsx +++ b/frontend/documentation/components/Chip.stories.tsx @@ -28,10 +28,6 @@ export const Accent: Story = { args: { children: '"hello"', variant: 'accent' }, } -export const Danger: Story = { - args: { children: 'Rejected', variant: 'danger' }, -} - export const Sizes: Story = { render: () => (
diff --git a/frontend/web/components/base/Chip/Chip.scss b/frontend/web/components/base/Chip/Chip.scss index a55ba57d0212..854427ce3f98 100644 --- a/frontend/web/components/base/Chip/Chip.scss +++ b/frontend/web/components/base/Chip/Chip.scss @@ -13,10 +13,6 @@ border-color: var(--color-border-action); } - &--danger { - border-color: var(--color-border-danger); - } - // Sizes (default is the base above). &--sm { padding: 3px 8px; diff --git a/frontend/web/components/base/Chip/Chip.tsx b/frontend/web/components/base/Chip/Chip.tsx index 2aae4d2edc89..296219372dda 100644 --- a/frontend/web/components/base/Chip/Chip.tsx +++ b/frontend/web/components/base/Chip/Chip.tsx @@ -5,7 +5,7 @@ import { colorIconSecondary } from 'common/theme/tokens' import './Chip.scss' export type ChipSize = 'default' | 'sm' | 'xs' -export type ChipVariant = 'neutral' | 'accent' | 'danger' +export type ChipVariant = 'neutral' | 'accent' export type ChipProps = { children: ReactNode @@ -30,7 +30,6 @@ export type ChipProps = { // bg + text come from token utilities; the variant border lives in Chip.scss. const VARIANT_UTILITIES: Record = { accent: 'bg-surface-action-subtle text-action', - danger: 'bg-transparent text-danger', neutral: 'bg-surface-subtle text-default', } @@ -64,7 +63,6 @@ const Chip = ({ { 'ds-chip--accent': variant === 'accent', 'ds-chip--clickable': interactive, - 'ds-chip--danger': variant === 'danger', [`ds-chip--${size}`]: size !== 'default', 'ds-chip--truncate': truncate, }, diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx index ad4e39a60d95..36929a02df08 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/FeatureDependenciesTab.tsx @@ -134,7 +134,6 @@ const FeatureDependenciesTab: FC = ({ flashedId={flashedId} addingName={addingName} isRemoving={removingId} - refusedId={staging.error?.prerequisiteId} onRemove={editing.onRemove} onUndo={(edge) => staging.undo(edge.prerequisite.id)} onSelect={(edge) => onSelectFeature(edge.prerequisite.id)} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx index f2d5881f0755..269c3c827df0 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/PrerequisitesTable.tsx @@ -2,6 +2,7 @@ import { FC, MouseEvent, ReactNode } from 'react' import cn from 'classnames' import { DependencyEdge } from 'common/types/responses' import Button from 'components/base/forms/Button' +import Chip from 'components/base/Chip' import Table, { TableBody, TableCell, @@ -14,7 +15,6 @@ import Icon from 'components/icons/Icon' import { PrerequisiteRow } from 'components/modals/create-feature/tabs/FeatureDependenciesTab/prerequisiteState' import StateToggle from 'components/feature-dependencies/StateToggle' import Satisfies from 'components/feature-dependencies/Satisfies' -import StagedChip from './StagedChip' import './PrerequisitesTable.scss' // The actions column only exists where the user can manage dependencies, so the @@ -28,8 +28,6 @@ type PrerequisitesTableProps = { isRemoving?: number onRemove: (edge: DependencyEdge) => void onUndo?: (edge: DependencyEdge) => void - // The held change the API rejected. - refusedId?: number onSelect: (edge: DependencyEdge) => void // The prerequisite being added, shown as a row of its own until the POST // resolves. Otherwise nothing moves between picking one and it appearing. @@ -48,7 +46,6 @@ const PrerequisitesTable: FC = ({ onRemove, onSelect, onUndo, - refusedId, rows, }) => (
@@ -97,10 +94,13 @@ const PrerequisitesTable: FC = ({ {!!staged && ( - + + {staged === 'add' ? 'Staged: add' : 'Staged: remove'} + )} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx deleted file mode 100644 index adb0f4ec7152..000000000000 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PrerequisitesTable/StagedChip.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { FC } from 'react' -import { StagedAction } from 'common/types/responses' -import Chip, { ChipVariant } from 'components/base/Chip' - -type StagedChipProps = { - action: StagedAction - isRefused?: boolean -} - -const variant = (action: StagedAction, isRefused?: boolean): ChipVariant => { - if (isRefused) return 'danger' - return action === 'add' ? 'accent' : 'neutral' -} - -const StagedChip: FC = ({ action, isRefused }) => ( - - {isRefused ? 'Rejected' : 'Staged'}: {action} - -) - -export default StagedChip diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx index a1e2fdd7b942..744f19fabac7 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/hooks/useStagedPrerequisites.tsx @@ -9,7 +9,7 @@ import { useDeleteFeatureDependencyMutation, } from 'common/services/useFeatureDependency' -export type StagingFailure = { message: string; prerequisiteId?: number } +export type StagingFailure = { message: string } // Dependency refusals answer { message }, other errors { detail }. const describeApiError = (e: unknown, fallback: string) => { @@ -79,29 +79,23 @@ export const useStagedPrerequisites = ({ } catch (e) { return { message: describeApiError(e, `Could not stage ${prerequisite.name}.`), - prerequisiteId: prerequisite.id, } } } return null } - // Staged changes leave the list, so a retry sends only the rest. + // Always cleared, or a later save would put the rest in a second change + // request. const stageInto = async (changeRequestId: number) => { setIsSubmitting(true) setError(null) const failure = await stage(changeRequestId) + setStaged([]) if (failure) { - const failedAt = staged.findIndex( - (change) => change.prerequisite.id === failure.prerequisiteId, - ) - setStaged(staged.slice(failedAt)) setError({ - ...failure, message: `Change request #${changeRequestId} was created without some dependency changes. ${failure.message}`, }) - } else { - setStaged([]) } setIsSubmitting(false) return failure