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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion frontend/common/utils/__tests__/diffDependencies.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { DependencyEdge } from 'common/types/responses'
import { diffDependencies } from 'common/utils/diffDependencies'
import {
countDependencyChanges,
diffDependencies,
isClosedChangeRequestStatus,
} from 'common/utils/diffDependencies'

const edge = (prerequisiteId: number): DependencyEdge => ({
feature: { id: 1, name: 'checkout' },
Expand Down Expand Up @@ -38,3 +42,41 @@ describe('diffDependencies', () => {
expect(diffDependencies([edge(1)], [edge(1)])).toEqual([])
})
})

describe('countDependencyChanges', () => {
const change = (action: 'add' | 'remove', id: number) => ({
action,
prerequisite: { id, name: `prereq_${id}` },
})

it('counts adds and removes, and reads both out', () => {
expect(
countDependencyChanges([
change('add', 1),
change('add', 2),
change('remove', 3),
]),
).toEqual({ added: 2, label: '2 added, 1 removed', removed: 1 })
})

it('leaves the empty side out of the label', () => {
expect(countDependencyChanges([change('remove', 1)])).toEqual({
added: 0,
label: '1 removed',
removed: 1,
})
})
})

describe('isClosedChangeRequestStatus', () => {
it.each([400, 404])('reads %s as closed', (status) => {
expect(isClosedChangeRequestStatus(status)).toBe(true)
})

it.each([500, 403, 'FETCH_ERROR', undefined])(
'reads %s as an error, not closed',
(status) => {
expect(isClosedChangeRequestStatus(status)).toBe(false)
},
)
})
15 changes: 15 additions & 0 deletions frontend/common/utils/diffDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,18 @@ export const diffDependencies = (
),
]
}

// A change request's adds and removes, with the counts read out as words.
export const countDependencyChanges = (changes: StagedDependencyChange[]) => {
const added = changes.filter(({ action }) => action === 'add').length
const removed = changes.length - added
const label = [added && `${added} added`, removed && `${removed} removed`]
.filter(Boolean)
.join(', ')
return { added, label, removed }
}

// The API answers a change request's dependencies with 400 once it is
// committed, and 404 once it is deleted.
export const isClosedChangeRequestStatus = (status?: number | string) =>
status === 400 || status === 404
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { skipToken } from '@reduxjs/toolkit/query'
import { useGetFeatureDependenciesQuery } from 'common/services/useFeatureDependency'
import { diffDependencies } from 'common/utils/diffDependencies'
import {
diffDependencies,
isClosedChangeRequestStatus,
} from 'common/utils/diffDependencies'

type UseDependencyChangesArgs = {
environmentId: string
Expand All @@ -23,10 +26,14 @@ export const useDependencyChanges = ({
const scoped = useGetFeatureDependenciesQuery(
skip ? skipToken : { changeRequestId, environmentId, featureId },
)
const isError = live.isError || scoped.isError
const status =
scoped.error && 'status' in scoped.error ? scoped.error.status : undefined
// Published or deleted since it was listed.
const isClosed = isClosedChangeRequestStatus(status)
const isError = live.isError || (scoped.isError && !isClosed)
const changes =
live.data && scoped.data
? diffDependencies(live.data.results, scoped.data.results)
: undefined
return { changes, isError }
return { changes, isClosed, isError }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { FC, ReactNode } from 'react'
import cn from 'classnames'
import Icon from 'components/icons/Icon'

type ChangeRequestLinkProps = {
projectId: number
environmentId: string
changeRequestId: number
children: ReactNode
className?: string
}

// A new tab, as leaving the page would drop the modal and anything staged in
// it without the close confirmation.
const ChangeRequestLink: FC<ChangeRequestLinkProps> = ({
changeRequestId,
children,
className,
environmentId,
projectId,
}) => (
<a
href={`/project/${projectId}/environment/${environmentId}/change-requests/${changeRequestId}`}
target='_blank'
rel='noopener noreferrer'
className={cn('d-inline-flex align-items-center gap-1', className)}
>
{children}
<Icon
name='open-external-link'
width={12}
role='img'
aria-label='opens in a new tab'
/>
</a>
)

export default ChangeRequestLink
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ChangeRequestFooter from './ChangeRequestFooter'
import ChangeRequestNotice from './ChangeRequestNotice'
import DependenciesHeader from './DependenciesHeader'
import PrerequisitePicker from './PrerequisitePicker'
import PendingChangeRequests from './PendingChangeRequests'
import { getDependenciesMode } from './dependenciesMode'
import { withStagedChanges } from './prerequisiteState'
import { usePrerequisites } from './hooks/usePrerequisites'
Expand Down Expand Up @@ -205,6 +206,16 @@ const FeatureDependenciesTab: FC<FeatureDependenciesTabProps> = ({
/>
)}

{/* The API lists them only to those who can create change requests. */}
{isChangeRequest && permission && (
<PendingChangeRequests
environmentId={environmentId}
featureId={projectFlag.id}
isEnabled={isEnabled}
projectId={projectId}
/>
)}

<ModalHR className='mt-4' />

<DependentFeatures
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { FC } from 'react'
import { ChangeRequestSummary } from 'common/types/responses'
import { colorIconSecondary } from 'common/theme/tokens'
import Accordion from 'components/base/Accordion'
import Banner from 'components/base/Banner'
import Icon from 'components/icons/Icon'
import Skeleton from 'components/Skeleton'
import DependencyChangesTable from 'components/feature-dependencies/DependencyChangesTable'
import { useDependencyChanges } from 'components/feature-dependencies/useDependencyChanges'
import { countDependencyChanges } from 'common/utils/diffDependencies'
import ChangeRequestLink from 'components/modals/create-feature/tabs/FeatureDependenciesTab/ChangeRequestLink'

type PendingChangeRequestProps = {
changeRequest: ChangeRequestSummary
environmentId: string
featureId: number
projectId: number
isEnabled: (prerequisiteId: number) => boolean
defaultOpen: boolean
}

// One open change request, shown only if it changes this feature's
// prerequisites. Each loads its own, as the change request page does.
const PendingChangeRequest: FC<PendingChangeRequestProps> = ({
changeRequest,
defaultOpen,
environmentId,
featureId,
isEnabled,
Comment on lines +22 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

nl -ba frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PendingChangeRequests/PendingChangeRequest.tsx | sed -n '15,95p'
nl -ba frontend/web/components/feature-dependencies/useDependencyChanges.ts | sed -n '1,65p'
nl -ba frontend/common/utils/diffDependencies.ts | sed -n '1,55p'

Repository: Flagsmith/flagsmith

Length of output: 6570


🏁 Script executed:

nl -ba frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab/PendingChangeRequests/PendingChangeRequests.tsx | sed -n '1,220p'
nl -ba frontend/common/services/useFeatureDependency.ts | sed -n '1,90p'
nl -ba api/features/dependencies/views.py | sed -n '80,120p'
nl -ba api/features/dependencies/services.py | sed -n '290,320p'

Repository: Flagsmith/flagsmith

Length of output: 9309


🏁 Script executed:

rg -n -F --glob '*.tsx' -e 'PendingChangeRequests' frontend/web/components/modals/create-feature/tabs/FeatureDependenciesTab

Repository: Flagsmith/flagsmith

Length of output: 1131


Make the dependency API honour change_request.

When this tab renders an open change request with staged dependency changes, the child requests live and change-request-scoped dependencies. The API ignores change_request and returns live dependencies for both requests. The diff is therefore empty, so the pending dependency changes are hidden. Update the dependency API to return dependencies as they would exist in the specified change request.

projectId,
}) => {
const { changes, isClosed, isError } = useDependencyChanges({
changeRequestId: changeRequest.id,
environmentId,
featureId,
})

if (isClosed) return null
if (isError) {
return (
<Banner type='error'>
Could not load the dependency changes in #{changeRequest.id}{' '}
{changeRequest.title}.
</Banner>
)
}
if (!changes) return <Skeleton width='100%' height={56} aria-hidden />
// Most change sets only touch values or overrides.
if (!changes.length) return null

const { added, label, removed } = countDependencyChanges(changes)

return (
<Accordion
defaultOpen={defaultOpen}
flush
title={
<>
<Icon
name='request'
width={16}
fill={colorIconSecondary}
aria-hidden
/>
<span className='pending-change-requests__title text-truncate'>
#{changeRequest.id} {changeRequest.title}
</span>
{/* Read out as words, as "+1 −2" means little to a screen reader. */}
<span
className='pending-change-requests__count d-flex flex-shrink-0 justify-content-end gap-2 ms-auto'
role='img'
aria-label={label}
>
{!!added && <span className='text-success'>+{added}</span>}
{!!removed && <span className='text-danger'>−{removed}</span>}
</span>
</>
}
>
<DependencyChangesTable changes={changes} isEnabled={isEnabled} />
{/* In the body, as the header is the toggle and a link cannot
sit inside a button. */}
<div className='d-flex justify-content-end px-3 py-2'>
<ChangeRequestLink
className='pending-change-requests__link text-action'
projectId={projectId}
environmentId={environmentId}
changeRequestId={changeRequest.id}
>
Open #{changeRequest.id}
</ChangeRequestLink>
</div>
</Accordion>
)
}

export default PendingChangeRequest
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Each change request hides itself when it changes no prerequisite, so the
// heading goes too once none are left.
.pending-change-requests:not(:has(.pending-change-requests__list > *)) {
display: none;
}

// B2, as the accordion's title, so it reads as secondary to the table.
.pending-change-requests__link {
font-size: 0.8125rem;
}

// Lets a long title shrink and truncate beside the count.
.pending-change-requests__title {
min-width: 0;
}

// Close to a fixed width, so the counts line up down the list.
.pending-change-requests__count {
min-width: 4rem;
font-variant-numeric: tabular-nums;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { FC } from 'react'
import { useGetChangeRequestsQuery } from 'common/services/useChangeRequest'
import Banner from 'components/base/Banner'
import PendingChangeRequest from './PendingChangeRequest'
import './PendingChangeRequests.scss'

type PendingChangeRequestsProps = {
environmentId: string
featureId: number
projectId: number
isEnabled: (prerequisiteId: number) => boolean
}

// Changes to this feature's prerequisites that are waiting in open change
// requests, one group per change request. Scheduled ones are left out, as
// the API refuses to list their dependencies.
const PendingChangeRequests: FC<PendingChangeRequestsProps> = ({
environmentId,
featureId,
isEnabled,
projectId,
}) => {
// Matches any change request with a change set for this feature.
const { data, isError } = useGetChangeRequestsQuery({
committed: false,
environmentId,
feature_id: featureId,
page_size: 100,
})
Comment thread
talissoncosta marked this conversation as resolved.

if (isError) {
return (
<Banner type='error' className='mt-4'>
Could not load the change requests waiting on this feature.
</Banner>
)
}

if (!data?.results.length) return null

return (
<div className='pending-change-requests mt-4'>
<h6 className='mb-1'>Pending in change requests</h6>
<p className='text-secondary mb-3'>
Not live yet. These go live when their change request is published.
</p>
<div className='pending-change-requests__list d-flex flex-column gap-3'>
{data.results.map((changeRequest) => (
<PendingChangeRequest
key={changeRequest.id}
changeRequest={changeRequest}
environmentId={environmentId}
featureId={featureId}
projectId={projectId}
isEnabled={isEnabled}
// One stays open; more are collapsed, and their counts lead.
defaultOpen={data.results.length === 1}
/>
))}
</div>
</div>
)
}

export default PendingChangeRequests
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './PendingChangeRequests'
Loading