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
114 changes: 114 additions & 0 deletions frontend/e2e/tests/deep-link-test.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { test, expect } from '../test-setup'
import { createHelpers, LONG_TIMEOUT, log } from '../helpers'
import { E2E_USER, PASSWORD, E2E_TEST_PROJECT } from '../config'
import Project from '../../common/project'

const PAGE_SIZE = 50
const FEATURE_COUNT = 60
const FEATURE_PREFIX = 'e2e_deeplink_'

type ApiList<T> = T[] | { results: T[] }

const unwrap = <T>(body: ApiList<T>): T[] =>
Array.isArray(body) ? body : body.results

test.describe('Deep link to feature slideout', () => {
test('opens the slideout for a feature on any page of the list @oss', async ({
page,
request,
}) => {
const { login } = createHelpers(page)
const api = Project.api

// Given - an authenticated API session and a project with > PAGE_SIZE features
log('Authenticate against the API')
const loginRes = await request.post(`${api}auth/login/`, {
data: { email: E2E_USER, password: PASSWORD },
})
expect(loginRes.ok()).toBeTruthy()
const { key } = await loginRes.json()
const headers = { Authorization: `Token ${key}` }

const project = unwrap<{ id: number; name: string }>(
await (await request.get(`${api}projects/`, { headers })).json(),
).find((p) => p.name === E2E_TEST_PROJECT)!
expect(project).toBeTruthy()

const environment = unwrap<{ id: number; name: string; api_key: string }>(
await (
await request.get(`${api}environments/?project=${project.id}`, {
headers,
})
).json(),
).find((e) => e.name === 'Development')!
expect(environment).toBeTruthy()

log(`Create ${FEATURE_COUNT} features`)
// Unique names per run so the flakiness check (`E2E_REPEAT` / `/e2e N`) does
// not collide with features created by a previous iteration.
const runId = Date.now()
const featureName = (i: number) =>
`${FEATURE_PREFIX}${runId}_${String(i).padStart(3, '0')}`
const createdFeatureIds: number[] = []

try {
const created = await Promise.all(
Array.from({ length: FEATURE_COUNT }, (_, i) =>
request.post(`${api}projects/${project.id}/features/`, {
data: { name: featureName(i) },
headers,
}),
),
)
for (const res of created) {
expect(res.ok()).toBeTruthy()
createdFeatureIds.push((await res.json()).id)
}

// The list renders sorted by name ascending, so page 1 holds the first
// PAGE_SIZE features and a feature on page 2 never mounts a row on page 1.
const listUrl = (pageNumber: number) =>
`${api}projects/${project.id}/features/?environment=${environment.id}&page=${pageNumber}&page_size=${PAGE_SIZE}&sort_field=name&sort_direction=ASC`
const page1 = await (await request.get(listUrl(1), { headers })).json()
const page2 = await (await request.get(listUrl(2), { headers })).json()
const onPageFeature = page1.results[0] as { id: number; name: string }
const offPageFeature = page2.results[0] as { id: number; name: string }
expect(onPageFeature).toBeTruthy()
expect(offPageFeature).toBeTruthy()
log(`On-page: ${onPageFeature.name}, off-page: ${offPageFeature.name}`)

await login(E2E_USER, PASSWORD)
const featuresPath = `/project/${project.id}/environment/${environment.api_key}/features`
const slideout = page.locator('.create-feature-modal')

// When/Then - this is the #7652 regression: a deep link to a feature that
// is NOT on the first page previously rendered the list without opening
// any modal, because the deep-link handler only fired for mounted rows.
await page.goto(`${featuresPath}?feature=${offPageFeature.id}&tab=value`)
await expect(slideout).toBeVisible({ timeout: LONG_TIMEOUT })
await expect(slideout).toContainText(offPageFeature.name)

// And - the existing on-page deep link still works (no regression). A
// fresh navigation reloads the page, dismissing the previous slideout.
await page.goto(`${featuresPath}?feature=${onPageFeature.id}&tab=value`)
await expect(slideout).toBeVisible({ timeout: LONG_TIMEOUT })
await expect(slideout).toContainText(onPageFeature.name)

// And - an unknown feature id degrades gracefully (no slideout, no crash).
await page.goto(`${featuresPath}?feature=999999999&tab=value`)
await expect(page.locator('[data-test="features-page"]')).toBeVisible({
timeout: LONG_TIMEOUT,
})
await expect(slideout).toBeHidden()
} finally {
// Clean up so reruns against a persistent project don't accumulate rows.
await Promise.all(
createdFeatureIds.map((id) =>
request.delete(`${api}projects/${project.id}/features/${id}/`, {
headers,
}),
),
)
}
})
})
108 changes: 74 additions & 34 deletions frontend/web/components/pages/features/FeaturesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import {
FeaturesTableFilters,
FeaturesSDKIntegration,
} from './components'
import { useDeepLinkedFeature } from './hooks/useDeepLinkedFeature'
import { useFeatureFilters } from './hooks/useFeatureFilters'
import { useRemoveFeatureWithToast } from './hooks/useRemoveFeatureWithToast'
import { useToggleFeatureWithToast } from './hooks/useToggleFeatureWithToast'
import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments'
import { useFeatureListWithApiKey } from 'common/hooks/useFeatureListWithApiKey'
import { useViewMode } from 'common/useViewMode'
import type { Pagination } from './types'
import type { ProjectFlag, FeatureState } from 'common/types/responses'
import type {
FeatureListProviderData,
FeatureState,
ProjectFlag,
} from 'common/types/responses'
import { ProjectPermission } from 'common/types/permissions.types'

const DEFAULT_PAGINATION: Pagination = {
Expand Down Expand Up @@ -88,6 +93,7 @@ const FeaturesPage: FC<FeaturesPageProps> = ({
const {
error: projectEnvError,
getEnvironment,
getEnvironmentIdFromKey,
project,
} = useProjectEnvironments(projectId)
const { currentData, data, error, isFetching, isLoading, refetch } =
Expand Down Expand Up @@ -174,6 +180,16 @@ const FeaturesPage: FC<FeaturesPageProps> = ({
[data?.pagination],
)

// Deep-link support: open the slideout for a `?feature=` target that isn't on
// the current page by fetching it and rendering a hidden FeatureRow below.
const deepLinkedFeature = useDeepLinkedFeature({
environmentApiKey: environmentId,
getEnvironmentIdFromKey,
isListLoaded: !!data && !isFetching,
projectFlags,
projectId,
})

usePageTracking({
context: {
environmentId,
Expand Down Expand Up @@ -245,39 +261,37 @@ const FeaturesPage: FC<FeaturesPageProps> = ({
[projectFlags, environmentFlags],
)

const renderFeatureRow = useCallback(
(projectFlag: ProjectFlag | SkeletonItem, i: number) => {
if (isSkeletonItem(projectFlag)) {
return <FeatureRowSkeleton key={`skeleton-${i}`} />
}

return (
<Permission
key={projectFlag.id}
level='environment'
tags={projectFlag.tags}
permission={Utils.getManageFeaturePermission(
Utils.changeRequestsEnabled(minimumChangeRequestApprovals),
)}
id={environmentId}
>
{({ permission }) => (
<FeatureRow
environmentFlags={environmentFlags}
permission={permission}
environmentId={environmentId}
projectId={projectId}
index={i}
toggleFlag={toggleFlag}
removeFlag={removeFlag}
projectFlag={projectFlag}
isCompact={isCompact}
experimentMode={defaultExperiment}
/>
)}
</Permission>
)
},
const renderPermissionedFeatureRow = useCallback(
(
projectFlag: ProjectFlag,
i: number,
environmentFlagsOverride?: FeatureListProviderData['environmentFlags'],
) => (
<Permission
key={projectFlag.id}
level='environment'
tags={projectFlag.tags}
permission={Utils.getManageFeaturePermission(
Utils.changeRequestsEnabled(minimumChangeRequestApprovals),
)}
id={environmentId}
>
{({ permission }) => (
<FeatureRow
environmentFlags={environmentFlagsOverride ?? environmentFlags}
permission={permission}
environmentId={environmentId}
projectId={projectId}
index={i}
toggleFlag={toggleFlag}
removeFlag={removeFlag}
projectFlag={projectFlag}
isCompact={isCompact}
experimentMode={defaultExperiment}
/>
)}
</Permission>
),
[
environmentFlags,
environmentId,
Expand All @@ -290,6 +304,17 @@ const FeaturesPage: FC<FeaturesPageProps> = ({
],
)

const renderFeatureRow = useCallback(
(projectFlag: ProjectFlag | SkeletonItem, i: number) => {
if (isSkeletonItem(projectFlag)) {
return <FeatureRowSkeleton key={`skeleton-${i}`} />
}

return renderPermissionedFeatureRow(projectFlag, i)
},
[renderPermissionedFeatureRow],
)

const handleNextPage = () => {
if (paging?.next) {
goToPage(page + 1)
Expand Down Expand Up @@ -378,6 +403,21 @@ const FeaturesPage: FC<FeaturesPageProps> = ({

<FormGroup className='mb-4'>{renderFeaturesList()}</FormGroup>

{deepLinkedFeature && (
<div className='d-none' data-test='deep-linked-feature'>
{renderPermissionedFeatureRow(
deepLinkedFeature.projectFlag,
-1,
deepLinkedFeature.environmentFlag
? {
[deepLinkedFeature.projectFlag.id]:
deepLinkedFeature.environmentFlag,
}
: {},
)}
</div>
)}

<FeaturesSDKIntegration
projectId={projectId}
environmentId={environmentId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
pickEnvironmentFlag,
shouldDeepFetchFeature,
} from 'components/pages/features/hooks/deepLinkedFeature'
import type { FeatureState } from 'common/types/responses'

const projectFlags = [{ id: 1 }, { id: 2 }, { id: 3 }]

describe('shouldDeepFetchFeature', () => {
it('returns null when there is no feature param', () => {
expect(
shouldDeepFetchFeature({
featureParam: undefined,
isListLoaded: true,
projectFlags,
}),
).toBeNull()
})

it('returns null when the list has not loaded yet', () => {
expect(
shouldDeepFetchFeature({
featureParam: '99',
isListLoaded: false,
projectFlags: [],
}),
).toBeNull()
})

it('returns null when the feature is on the current page', () => {
expect(
shouldDeepFetchFeature({
featureParam: '2',
isListLoaded: true,
projectFlags,
}),
).toBeNull()
})

it('returns the feature id when the feature is off the current page', () => {
expect(
shouldDeepFetchFeature({
featureParam: '99',
isListLoaded: true,
projectFlags,
}),
).toEqual({ featureId: 99 })
})

it('returns null for a non-numeric feature param', () => {
expect(
shouldDeepFetchFeature({
featureParam: 'not-a-number',
isListLoaded: true,
projectFlags,
}),
).toBeNull()
})
})

describe('pickEnvironmentFlag', () => {
const make = (id: number, feature: number) =>
({ feature, id } as FeatureState)

it('returns the state matching the feature id', () => {
const results = [make(10, 1), make(11, 99), make(12, 2)]
expect(pickEnvironmentFlag(results, 99)).toBe(results[1])
})

it('falls back to the first result when there is no exact match', () => {
const results = [make(10, 1), make(12, 2)]
expect(pickEnvironmentFlag(results, 99)).toBe(results[0])
})

it('returns undefined when there are no results', () => {
expect(pickEnvironmentFlag([], 99)).toBeUndefined()
expect(pickEnvironmentFlag(undefined, 99)).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { FeatureState } from 'common/types/responses'

/**
* Decides whether the `?feature=` deep link targets a feature that is NOT on the
* currently loaded page, and therefore needs to be fetched directly.
*
* Returns `null` (no direct fetch needed) when there is no param, the list has
* not loaded yet, the param is not a valid id, or the feature is already on the
* current page (in which case its rendered row handles the deep link).
*/
export function shouldDeepFetchFeature(args: {
featureParam: string | undefined
projectFlags: { id: number }[]
isListLoaded: boolean
}): { featureId: number } | null {
const { featureParam, isListLoaded, projectFlags } = args
if (!isListLoaded || !featureParam) {
return null
}
const featureId = Number(featureParam)
if (!Number.isInteger(featureId)) {
return null
}
const isOnPage = projectFlags.some((flag) => flag.id === featureId)
return isOnPage ? null : { featureId }
}

/** Pick the environment feature state matching `featureId`, falling back to the
* first result, or `undefined` when there is none. */
export function pickEnvironmentFlag(
results: FeatureState[] | undefined,
featureId: number,
): FeatureState | undefined {
return (
results?.find((featureState) => featureState.feature === featureId) ??
results?.[0]
)
}
Loading
Loading