-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix(flags): make recent feature flags show and link correctly #110227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a91475f
ee5620d
36dc61c
9a3e80f
549f364
472fb46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,30 +1,68 @@ | ||
| import { flagSelectorButtonLabel } from './FlagSelector' | ||
|
|
||
| describe('flagSelectorButtonLabel', () => { | ||
| // The picker strips `key` off recently-used flags, so a pick from the Recent tab labels itself | ||
| // with `name`, which on a flag holds a description like "Feature Flag for Early Access Feature | ||
| // Foo". The resolved key has to win over that. | ||
| const RECENT_PICK = { id: 7, label: 'Feature Flag for Early Access Feature Foo' } | ||
|
|
||
| test.each([ | ||
| ['resolved key wins over a recent pick', 'foo', 7, RECENT_PICK, 'Fallback', 'foo'], | ||
| ['pick stands in while the key is still loading', '', 7, RECENT_PICK, 'Fallback', RECENT_PICK.label], | ||
| ['pick stands in when the key lookup fails', '', 7, { id: 7, label: 'foo' }, 'Fallback', 'foo'], | ||
| ['pick the caller never stored is dropped', '', undefined, RECENT_PICK, 'Fallback', 'Fallback'], | ||
| ['pick for a different flag is dropped', '', 9, RECENT_PICK, 'Fallback', 'Fallback'], | ||
| ['falls back to the initial label with nothing picked', '', undefined, undefined, 'Fallback', 'Fallback'], | ||
| ['falls back to the default with no initial label', '', undefined, undefined, undefined, 'Select flag'], | ||
| ])( | ||
| '%s', | ||
| ( | ||
| _name: string, | ||
| flagKey: string, | ||
| value: number | undefined, | ||
| pickedFlag: { id: number; label: string } | undefined, | ||
| initialButtonLabel: string | undefined, | ||
| expected: string | ||
| ) => { | ||
| expect(flagSelectorButtonLabel({ flagKey, value, pickedFlag, initialButtonLabel })).toBe(expected) | ||
| } | ||
| ) | ||
| import { FeatureFlagBasicType } from '~/types' | ||
|
|
||
| import { flagSelectorButtonLabel, pickedFeatureFlag } from './FlagSelector' | ||
|
|
||
| describe('FlagSelector', () => { | ||
| describe('flagSelectorButtonLabel', () => { | ||
| const PICK = { id: 7, label: 'checkout-redesign' } | ||
|
|
||
| test.each([ | ||
| ['resolved key wins over a pick', 'foo', 7, PICK, 'Fallback', 'foo'], | ||
| ['pick stands in while the key is still loading', '', 7, PICK, 'Fallback', PICK.label], | ||
| ['pick stands in when the key lookup fails', '', 7, { id: 7, label: 'foo' }, 'Fallback', 'foo'], | ||
| ['pick the caller never stored is dropped', '', undefined, PICK, 'Fallback', 'Fallback'], | ||
| ['pick for a different flag is dropped', '', 9, PICK, 'Fallback', 'Fallback'], | ||
| ['falls back to the initial label with nothing picked', '', undefined, undefined, 'Fallback', 'Fallback'], | ||
| ['falls back to the default with no initial label', '', undefined, undefined, undefined, 'Select flag'], | ||
| ])( | ||
| '%s', | ||
| ( | ||
| _name: string, | ||
| flagKey: string, | ||
| value: number | undefined, | ||
| pickedFlag: { id: number; label: string } | undefined, | ||
| initialButtonLabel: string | undefined, | ||
| expected: string | ||
| ) => { | ||
| expect(flagSelectorButtonLabel({ flagKey, value, pickedFlag, initialButtonLabel })).toBe(expected) | ||
| } | ||
| ) | ||
| }) | ||
|
|
||
| describe('pickedFeatureFlag', () => { | ||
| const FULL_FLAG = { | ||
| id: 7, | ||
| key: 'checkout-redesign', | ||
| name: '', | ||
| active: true, | ||
| filters: { groups: [] }, | ||
| } as unknown as FeatureFlagBasicType | ||
|
|
||
| it('hands back the whole flag when the row is one', () => { | ||
| expect(pickedFeatureFlag(FULL_FLAG, 7)).toEqual({ id: 7, key: 'checkout-redesign', flag: FULL_FLAG }) | ||
| }) | ||
|
|
||
| // A Recent row holds only what labels it. Returning it as `flag` is what let callers store a | ||
| // feature flag with no key and no targeting config, which the replay trigger and the survey | ||
| // variant selector both read. | ||
| it('withholds the flag when the row is a stored summary', () => { | ||
| expect(pickedFeatureFlag({ name: '', id: 7, key: 'checkout-redesign' }, 7)).toEqual({ | ||
| id: 7, | ||
| key: 'checkout-redesign', | ||
| flag: undefined, | ||
| }) | ||
| }) | ||
|
|
||
| it('takes the id from the picked value when the row carries none', () => { | ||
| expect(pickedFeatureFlag({ key: 'checkout-redesign' }, 7)?.id).toBe(7) | ||
| }) | ||
|
|
||
| it.each([ | ||
| ['a row with no key', { name: '', id: 7 }, 7], | ||
| ['a row with an empty key', { name: '', id: 7, key: '' }, 7], | ||
| ['a row with no resolvable id', { key: 'checkout-redesign' }, null], | ||
| ])('is not a selection: %s', (_name: string, item: Record<string, unknown>, value: number | null) => { | ||
| expect(pickedFeatureFlag(item, value)).toBeNull() | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,11 @@ import { useValues } from 'kea' | |
| import { useState } from 'react' | ||
|
|
||
| import { TaxonomicFilter } from 'lib/components/TaxonomicFilter/TaxonomicFilter' | ||
| import { TaxonomicFilterGroupType, TaxonomicFilterLogicProps } from 'lib/components/TaxonomicFilter/types' | ||
| import { | ||
| TaxonomicFilterGroupType, | ||
| TaxonomicFilterLogicProps, | ||
| TaxonomicFilterValue, | ||
| } from 'lib/components/TaxonomicFilter/types' | ||
| import { LemonButton } from 'lib/lemon-ui/LemonButton' | ||
| import { Popover } from 'lib/lemon-ui/Popover' | ||
| import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic' | ||
|
|
@@ -11,12 +15,39 @@ import { FeatureFlagBasicType } from '~/types' | |
|
|
||
| interface FlagSelectorProps { | ||
| value: number | undefined | ||
| onChange: (id: number, key: string, flag: FeatureFlagBasicType) => void | ||
| /** | ||
| * `flag` is absent when the picked row is a stored summary rather than the flag itself. A caller | ||
| * that reads the flag's configuration loads it from `id` in that case. | ||
| */ | ||
| onChange: (id: number, key: string, flag?: FeatureFlagBasicType) => void | ||
| readOnly?: boolean | ||
| disabledReason?: string | ||
| initialButtonLabel?: string | ||
| } | ||
|
|
||
| interface PickedFeatureFlag { | ||
| id: number | ||
| key: string | ||
| flag?: FeatureFlagBasicType | ||
| } | ||
|
|
||
| /** | ||
| * A row from the Feature Flags list is the flag itself. A row from the Recent category is a stored | ||
| * summary of one: it holds the id and key its label needs, and none of the flag's configuration. A | ||
| * caller reads `filters` and `active` off the flag, so a summary must not stand in for one. | ||
| */ | ||
| export function pickedFeatureFlag(item: unknown, pickedValue: TaxonomicFilterValue): PickedFeatureFlag | null { | ||
| const row = typeof item === 'object' && item !== null ? (item as Record<string, unknown>) : {} | ||
| // The Feature Flags group keys every row by flag id, so the picked value carries the id even | ||
| // when the row itself does not. | ||
| const id = typeof row.id === 'number' ? row.id : typeof pickedValue === 'number' ? pickedValue : null | ||
| const key = typeof row.key === 'string' && row.key ? row.key : null | ||
| if (id === null || key === null) { | ||
| return null | ||
| } | ||
| return { id, key, flag: 'filters' in row ? (row as unknown as FeatureFlagBasicType) : undefined } | ||
| } | ||
|
|
||
| interface PickedFlag { | ||
| id: number | ||
| label: string | ||
|
|
@@ -34,8 +65,7 @@ export function flagSelectorButtonLabel({ | |
| initialButtonLabel: string | undefined | ||
| }): string { | ||
| // A pick only labels the button while it still agrees with `value`, so a pick the caller never | ||
| // stored can't linger. It ranks below `flagKey` because a pick from the recents list carries no | ||
| // key and falls back to `name`, which on a flag holds the description rather than a title. | ||
| // stored can't linger. It ranks below `flagKey` because the lookup holds the flag's current key. | ||
| // `flagKey` is '' both while the lookup is in flight and when it fails, which is the gap the | ||
| // pick covers. | ||
| const pickedLabel = pickedFlag && pickedFlag.id === value ? pickedFlag.label : undefined | ||
|
|
@@ -50,23 +80,25 @@ export function FlagSelector({ | |
| initialButtonLabel, | ||
| }: FlagSelectorProps): JSX.Element { | ||
| const [visible, setVisible] = useState(false) | ||
| // Recently-used flags are persisted with just `{ name, id }` (no `key`), so a pick from the | ||
| // recents list has nothing to label the button with until the live lookup resolves. Hold | ||
| // whatever the picker handed us to cover that gap. | ||
| // The live lookup of a freshly picked flag takes a moment, so hold the label the picker handed | ||
| // us to cover that gap. | ||
| const [selectedFlag, setSelectedFlag] = useState<PickedFlag | undefined>(undefined) | ||
|
|
||
| const { featureFlag } = useValues(featureFlagLogic({ id: value || 'link' })) | ||
|
|
||
| const taxonomicFilterLogicProps: TaxonomicFilterLogicProps = { | ||
| groupType: TaxonomicFilterGroupType.FeatureFlags, | ||
| value: value, | ||
| onChange: (_, __, item) => { | ||
| // The picker can hand back an item with no flag id; that isn't a selection. | ||
| if ('id' in item && item.id) { | ||
| setSelectedFlag({ id: item.id, label: item.key || item.name }) | ||
| onChange(item.id, item.key, item) | ||
| setVisible(false) | ||
| onChange: (_, pickedValue, item) => { | ||
| const picked = pickedFeatureFlag(item, pickedValue) | ||
| // A row the picker cannot resolve to a flag isn't a selection. No category renders one, | ||
| // so this only guards against a stored row shape that predates the fields above. | ||
| if (!picked) { | ||
| return | ||
| } | ||
| setSelectedFlag({ id: picked.id, label: picked.key }) | ||
| onChange(picked.id, picked.key, picked.flag) | ||
|
Comment on lines
+99
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolve Recent flag keys before saving replay triggersIssue descriptionRecent entries now store a flag key for up to 30 days. If the flag is renamed, selecting its Recent entry sends the old key through onChange. FlagTriggerSelector and triggerGroupFormLogic.addFlag persist that key without resolving the flag ID. The recording configuration forwards the stored key to SDKs unchanged. The replay trigger therefore uses the old key instead of the selected flag's current key. The live lookup updates only the button label and does not correct the saved trigger. Why we think it's a valid issue
Suggested fixResolve a Recent summary by ID before calling onChange. Pass the fetched flag's current key, for example onChange(currentFlag.id, currentFlag.key, currentFlag). Reuse the loaded featureFlag only when its ID matches picked.id. Refresh the stored Recent entry with the resolved key. Prompt to fix with AI (copy-paste)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escalated: the issue is real, but the fix site is a design choice for the PR author.
More detail
How this was verifiedNo code change, so I ran no tests. I checked the code paths by reading FlagSelector.tsx, FlagTrigger/Selector.tsx, triggerGroupFormLogic.ts, posthog/api/team.py, posthog/models/remote_config.py and products/feature_flags/backend/session_recording_links.py at the current head. |
||
| setVisible(false) | ||
| }, | ||
| taxonomicGroupTypes: [TaxonomicFilterGroupType.FeatureFlags], | ||
| optionsFromProp: undefined, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.