Skip to content
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

feat(toolbar): support feature flag payload overrides in the flag toolbar #28058

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions frontend/src/scenes/experiments/ExperimentCodeSnippets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function JSSnippet({ flagKey, variant }: SnippetProps): JSX.Element {
<b>Test that it works</b>
</div>
<CodeSnippet language={Language.JavaScript} wrap>
{`posthog.featureFlags.override({'${flagKey}': '${variant}'})`}
{`posthog.featureFlags.overrideFeatureFlags({ flags: {'${flagKey}': '${variant}'})`}
</CodeSnippet>
</div>
)
Expand Down Expand Up @@ -120,7 +120,7 @@ function App() {
}

// You can also test your code by overriding the feature flag:
posthog.featureFlags.override({'${flagKey}': '${variant}'})`}
posthog.featureFlags.overrideFeatureFlags({ flags: {'${flagKey}': '${variant}'})`}
</CodeSnippet>
</>
)
Expand Down
164 changes: 108 additions & 56 deletions frontend/src/toolbar/flags/FlagsToolbarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,40 @@ import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { AnimatedCollapsible } from 'lib/components/AnimatedCollapsible'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonInput } from 'lib/lemon-ui/LemonInput'
import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea'
import { Link } from 'lib/lemon-ui/Link'
import { Spinner } from 'lib/lemon-ui/Spinner'
import { useEffect } from 'react'
import { debounce } from 'lib/utils'
import { useEffect, useMemo } from 'react'
import { urls } from 'scenes/urls'

import { ToolbarMenu } from '~/toolbar/bar/ToolbarMenu'
import { flagsToolbarLogic } from '~/toolbar/flags/flagsToolbarLogic'
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'

export const FlagsToolbarMenu = (): JSX.Element => {
const { searchTerm, filteredFlags, userFlagsLoading } = useValues(flagsToolbarLogic)
const { searchTerm, filteredFlags, userFlagsLoading, draftPayloads, payloadErrors } = useValues(flagsToolbarLogic)
const {
setSearchTerm,
setOverriddenUserFlag,
deleteOverriddenUserFlag,
getUserFlags,
checkLocalOverrides,
setFeatureFlagValueFromPostHogClient,
setDraftPayload,
savePayloadOverride,
} = useActions(flagsToolbarLogic)
const { apiURL, posthog: posthogClient } = useValues(toolbarConfigLogic)

const debouncedSetDraftPayload = useMemo(
() => debounce((key: string, value: string) => setDraftPayload(key, value), 300),
[setDraftPayload]
)

useEffect(() => {
posthogClient?.onFeatureFlags(setFeatureFlagValueFromPostHogClient)
getUserFlags()
Expand All @@ -48,62 +58,102 @@ export const FlagsToolbarMenu = (): JSX.Element => {
<ToolbarMenu.Body>
<div className="mt-1">
{filteredFlags.length > 0 ? (
filteredFlags.map(({ feature_flag, value, hasOverride, hasVariants, currentValue }) => (
<div className={clsx('-mx-1 py-1 px-2', hasOverride && 'bg-mark')} key={feature_flag.key}>
<div className="flex flex-row items-center">
<div className="flex-1 truncate">
<Link
className="font-medium"
to={`${apiURL}${
feature_flag.id
? urls.featureFlag(feature_flag.id)
: urls.featureFlags()
}`}
subtle
target="_blank"
>
{feature_flag.key}
<IconOpenInNew />
</Link>
filteredFlags.map(
({ feature_flag, value, hasOverride, hasVariants, currentValue, payloadOverride }) => (
<div
className={clsx('-mx-1 py-1 px-2', hasOverride && 'bg-mark')}
key={feature_flag.key}
>
<div className="flex flex-row items-center">
<div className="flex-1 truncate">
<Link
className="font-medium"
to={`${apiURL}${
feature_flag.id
? urls.featureFlag(feature_flag.id)
: urls.featureFlags()
}`}
subtle
target="_blank"
>
{feature_flag.key}
<IconOpenInNew />
</Link>
</div>

<LemonSwitch
checked={!!currentValue}
onChange={(checked) => {
const newValue =
hasVariants && checked
? (feature_flag.filters?.multivariate?.variants[0]
?.key as string)
: checked
if (newValue === value && hasOverride) {
deleteOverriddenUserFlag(feature_flag.key)
} else {
setOverriddenUserFlag(feature_flag.key, newValue)
}
}}
/>
</div>

<LemonSwitch
checked={!!currentValue}
onChange={(checked) => {
const newValue =
hasVariants && checked
? (feature_flag.filters?.multivariate?.variants[0]?.key as string)
: checked
if (newValue === value && hasOverride) {
deleteOverriddenUserFlag(feature_flag.key)
} else {
setOverriddenUserFlag(feature_flag.key, newValue)
}
}}
/>
</div>
<AnimatedCollapsible collapsed={!currentValue}>
<>
{hasVariants ? (
<LemonRadio
className={clsx('pt-1 pl-4 w-full', hasOverride && 'bg-mark')}
value={typeof currentValue === 'string' ? currentValue : undefined}
options={
feature_flag.filters?.multivariate?.variants.map((variant) => ({
label: `${variant.key} - ${variant.name} (${variant.rollout_percentage}%)`,
value: variant.key,
})) || []
}
onChange={(newValue) => {
if (newValue === value && hasOverride) {
deleteOverriddenUserFlag(feature_flag.key)
} else {
setOverriddenUserFlag(feature_flag.key, newValue)
}
}}
/>
) : null}

<AnimatedCollapsible collapsed={!hasVariants || !currentValue}>
<LemonRadio
className={clsx('pt-1 pl-4 w-full', hasOverride && 'bg-mark')}
value={typeof currentValue === 'string' ? currentValue : undefined}
options={
feature_flag.filters?.multivariate?.variants.map((variant) => ({
label: `${variant.key} - ${variant.name} (${variant.rollout_percentage}%)`,
value: variant.key,
})) || []
}
onChange={(newValue) => {
if (newValue === value && hasOverride) {
deleteOverriddenUserFlag(feature_flag.key)
} else {
setOverriddenUserFlag(feature_flag.key, newValue)
}
}}
/>
</AnimatedCollapsible>
</div>
))
<div className={clsx('py-1', hasVariants && 'pl-4')}>
<label className="text-xs font-semibold">Payload</label>
<div className="flex gap-2 items-center mt-1">
<LemonTextArea
className={clsx(
'font-mono text-xs flex-1 !rounded',
payloadErrors[feature_flag.key] && 'border-danger'
)}
value={
draftPayloads[feature_flag.key] ??
(payloadOverride
? JSON.stringify(payloadOverride, null, 2)
: '')
}
onChange={(val) =>
debouncedSetDraftPayload(feature_flag.key, val)
}
placeholder='{"key": "value"}'
minRows={2}
/>
<LemonButton
size="small"
type="primary"
onClick={() => savePayloadOverride(feature_flag.key)}
>
Save
</LemonButton>
</div>
</div>
</>
</AnimatedCollapsible>
</div>
)
)
) : (
<div className="flex flex-row items-center p-1">
{userFlagsLoading ? (
Expand All @@ -119,7 +169,9 @@ export const FlagsToolbarMenu = (): JSX.Element => {
</ToolbarMenu.Body>

<ToolbarMenu.Footer>
<span className="text-xs">Note: overriding feature flags will only affect this browser.</span>
<span className="text-xs">
Note: overriding feature flags and payloads will only affect this browser.
</span>
</ToolbarMenu.Footer>
</ToolbarMenu>
)
Expand Down
88 changes: 81 additions & 7 deletions frontend/src/toolbar/flags/flagsToolbarLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { CombinedFeatureFlagAndValueType } from '~/types'

import type { flagsToolbarLogicType } from './flagsToolbarLogicType'

export type PayloadOverrides = Record<string, any>

export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
path(['toolbar', 'flags', 'flagsToolbarLogic']),
connect(() => ({
Expand All @@ -22,11 +24,23 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
flags,
variants,
}),
setOverriddenUserFlag: (flagKey: string, overrideValue: string | boolean) => ({ flagKey, overrideValue }),
setOverriddenUserFlag: (
flagKey: string,
overrideValue: string | boolean,
payloadOverride?: PayloadOverrides
) => ({
flagKey,
overrideValue,
payloadOverride,
}),
setPayloadOverride: (flagKey: string, payload: any) => ({ flagKey, payload }),
deleteOverriddenUserFlag: (flagKey: string) => ({ flagKey }),
setSearchTerm: (searchTerm: string) => ({ searchTerm }),
checkLocalOverrides: true,
storeLocalOverrides: (localOverrides: Record<string, string | boolean>) => ({ localOverrides }),
setDraftPayload: (flagKey: string, draftPayload: string) => ({ flagKey, draftPayload }),
savePayloadOverride: (flagKey: string) => ({ flagKey }),
setPayloadError: (flagKey: string, error: string | null) => ({ flagKey, error }),
}),
loaders(({ values }) => ({
userFlags: [
Expand Down Expand Up @@ -70,11 +84,52 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
},
},
],
payloadOverrides: [
{} as PayloadOverrides,
{
setPayloadOverride: (state, { flagKey, payload }) => ({
...state,
[flagKey]: payload,
}),
deleteOverriddenUserFlag: (state, { flagKey }) => {
const newState = { ...state }
delete newState[flagKey]
return newState
},
},
],
draftPayloads: [
{} as Record<string, string>,
{
setDraftPayload: (state, { flagKey, draftPayload }) => ({
...state,
[flagKey]: draftPayload,
}),
deleteOverriddenUserFlag: (state, { flagKey }) => {
const newState = { ...state }
delete newState[flagKey]
return newState
},
},
],
payloadErrors: [
{} as Record<string, string | null>,
{
setPayloadError: (state, { flagKey, error }) => ({
...state,
[flagKey]: error,
}),
setDraftPayload: (state, { flagKey }) => ({
...state,
[flagKey]: null,
}),
},
],
}),
selectors({
userFlagsWithOverrideInfo: [
(s) => [s.userFlags, s.localOverrides, s.posthogClientFlagValues],
(userFlags, localOverrides, posthogClientFlagValues) => {
(s) => [s.userFlags, s.localOverrides, s.posthogClientFlagValues, s.payloadOverrides],
(userFlags, localOverrides, posthogClientFlagValues, payloadOverrides) => {
return userFlags.map((flag) => {
const hasVariants = (flag.feature_flag.filters?.multivariate?.variants?.length || 0) > 0

Expand All @@ -88,6 +143,7 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
hasVariants,
currentValue,
hasOverride: flag.feature_flag.key in localOverrides,
payloadOverride: payloadOverrides[flag.feature_flag.key],
}
})
},
Expand Down Expand Up @@ -115,12 +171,19 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
actions.storeLocalOverrides(locallyOverrideFeatureFlags)
}
},
setOverriddenUserFlag: ({ flagKey, overrideValue }) => {
setOverriddenUserFlag: ({ flagKey, overrideValue, payloadOverride }) => {
const clientPostHog = values.posthog
if (clientPostHog) {
clientPostHog.featureFlags.override({ ...values.localOverrides, [flagKey]: overrideValue })
const payloads = payloadOverride ? { [flagKey]: payloadOverride } : undefined
clientPostHog.featureFlags.overrideFeatureFlags({
flags: { ...values.localOverrides, [flagKey]: overrideValue },
payloads: payloads,
})
toolbarPosthogJS.capture('toolbar feature flag overridden')
actions.checkLocalOverrides()
if (payloadOverride) {
actions.setPayloadOverride(flagKey, payloadOverride)
}
clientPostHog.featureFlags.reloadFeatureFlags()
}
},
Expand All @@ -130,15 +193,26 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
const updatedFlags = { ...values.localOverrides }
delete updatedFlags[flagKey]
if (Object.keys(updatedFlags).length > 0) {
clientPostHog.featureFlags.override({ ...updatedFlags })
clientPostHog.featureFlags.overrideFeatureFlags({ flags: updatedFlags })
} else {
clientPostHog.featureFlags.override(false)
clientPostHog.featureFlags.overrideFeatureFlags(false)
}
toolbarPosthogJS.capture('toolbar feature flag override removed')
actions.checkLocalOverrides()
clientPostHog.featureFlags.reloadFeatureFlags()
}
},
savePayloadOverride: ({ flagKey }) => {
try {
const draftPayload = values.draftPayloads[flagKey]
const payload = draftPayload ? JSON.parse(draftPayload) : null
actions.setPayloadError(flagKey, null)
actions.setOverriddenUserFlag(flagKey, true, payload)
} catch (e) {
actions.setPayloadError(flagKey, 'Invalid JSON')
console.error('Invalid JSON:', e)
}
},
})),
permanentlyMount(),
])
Expand Down
Loading