Skip to content

Commit 2a98437

Browse files
committed
✨ [FFL-2596] add flag overrides to the feature flags tab
Builds on FFL-2597: adds the override engine (writes to the inspected page's localStorage via the DatadogDevtools contract), per-variant override buttons + revert on each catalog row, a manual override-by-key form, and clear-all / save-and-reload controls.
1 parent f937bcb commit 2a98437

18 files changed

Lines changed: 1593 additions & 144 deletions

developer-extension/src/panel/components/tabs/flagsTab/flagCatalogList.tsx

Lines changed: 127 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
1-
import { ActionIcon, Badge, Box, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
2-
import { IconCopy } from '@tabler/icons-react'
3-
import React from 'react'
4-
import type { CatalogFlag } from './flagCatalog'
5-
import type { FlagCatalogState } from './useFlagCatalog'
1+
import { ActionIcon, Box, Button, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
2+
import { IconArrowBackUp, IconCopy } from '@tabler/icons-react'
3+
import React, { type ReactNode } from 'react'
4+
import type { CatalogFlag } from './flagsRequests'
5+
import { useFlagsContext } from './flagsContext'
6+
import { validateOverrideValue } from './flagTypes'
7+
import { getOverride, type FlagOverride } from './inspectedPageFlags'
8+
9+
export function FlagCatalogBody() {
10+
const { catalog, bottomFlags } = useFlagsContext()
611

7-
export function FlagCatalogBody({
8-
catalog,
9-
flags,
10-
total,
11-
}: {
12-
catalog: FlagCatalogState
13-
flags: CatalogFlag[]
14-
total: number
15-
}) {
1612
if (catalog.loading) {
1713
return (
1814
<Group justify="center" py="xl">
@@ -28,31 +24,102 @@ export function FlagCatalogBody({
2824
return (
2925
<>
3026
<Text c="dimmed" size="xs">
31-
{total} {total === 1 ? 'flag' : 'flags'}
27+
{catalog.total} {catalog.total === 1 ? 'flag' : 'flags'}
3228
</Text>
3329
<Space h="xs" />
34-
<Box style={{ border: '1px solid var(--mantine-color-gray-2)', borderRadius: 'var(--mantine-radius-sm)' }}>
35-
{flags.length === 0 ? (
36-
<Text c="dimmed" p="md">
37-
No flags match.
38-
</Text>
39-
) : (
40-
flags.map((flag) => <FlagRow key={flag.key} flag={flag} />)
41-
)}
42-
</Box>
30+
<FlagList
31+
flags={bottomFlags}
32+
borderColor="var(--mantine-color-gray-2)"
33+
// `bottomFlags` is the page minus overridden flags (those are pinned above). Only call it "no
34+
// match" when the server total is 0; otherwise this page's flags are all overridden.
35+
emptyMessage={
36+
catalog.total === 0 ? 'No flags match.' : 'All flags on this page are overridden — see Local overrides above.'
37+
}
38+
/>
39+
</>
40+
)
41+
}
42+
43+
/**
44+
* The always-visible "Local overrides" section shown above the paginated catalog. Lists every
45+
* overridden flag (regardless of which catalog page it's on), so overrides are never buried by
46+
* pagination. The overridden flags' catalog data comes from the context (see useOverriddenFlags).
47+
*/
48+
export function OverridesSection() {
49+
const { overriddenFlags } = useFlagsContext()
50+
51+
if (overriddenFlags.length === 0) {
52+
return null
53+
}
54+
return (
55+
<>
56+
<Text fw={600} size="sm">
57+
Local overrides ({overriddenFlags.length})
58+
</Text>
59+
<Space h="xs" />
60+
<FlagList flags={overriddenFlags} borderColor="var(--mantine-color-violet-2)" />
4361
</>
4462
)
4563
}
4664

47-
function FlagRow({ flag }: { flag: CatalogFlag }) {
65+
// Renders a bordered list of flag rows, or `emptyMessage` when there are none. Shared by the catalog
66+
// body and the "Local overrides" section — they differ only in border color and empty copy. Reads
67+
// the override state + actions from context so each row's wiring stays identical.
68+
function FlagList({
69+
flags,
70+
borderColor,
71+
emptyMessage,
72+
}: {
73+
flags: CatalogFlag[]
74+
borderColor: string
75+
emptyMessage?: ReactNode
76+
}) {
77+
const { overrides, applyOverride, removeOverride } = useFlagsContext()
78+
return (
79+
<Box style={{ border: `1px solid ${borderColor}`, borderRadius: 'var(--mantine-radius-sm)' }}>
80+
{flags.length === 0 ? (
81+
<Text c="dimmed" p="md">
82+
{emptyMessage}
83+
</Text>
84+
) : (
85+
flags.map((flag) => (
86+
<FlagRow
87+
key={flag.key}
88+
flag={flag}
89+
override={getOverride(overrides, flag.key)}
90+
onSelectVariant={applyOverride}
91+
onRevert={removeOverride}
92+
/>
93+
))
94+
)}
95+
</Box>
96+
)
97+
}
98+
99+
function FlagRow({
100+
flag,
101+
override,
102+
onSelectVariant,
103+
onRevert,
104+
}: {
105+
flag: CatalogFlag
106+
override: FlagOverride | undefined
107+
onSelectVariant: (flagKey: string, override: FlagOverride) => void
108+
onRevert: (flagKey: string) => void
109+
}) {
110+
const overridden = override !== undefined
111+
48112
return (
49113
<Group
50114
justify="space-between"
51115
wrap="nowrap"
52116
align="center"
53117
px="sm"
54118
py="xs"
55-
style={{ borderBottom: '1px solid var(--mantine-color-gray-1)' }}
119+
style={{
120+
borderBottom: '1px solid var(--mantine-color-gray-1)',
121+
backgroundColor: overridden ? 'var(--mantine-color-violet-0)' : undefined,
122+
}}
56123
>
57124
<Box style={{ minWidth: 0, flex: 1 }}>
58125
<Text size="sm" fw={600} truncate>
@@ -61,16 +128,41 @@ function FlagRow({ flag }: { flag: CatalogFlag }) {
61128
<FlagKey value={flag.key} />
62129
</Box>
63130
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
131+
{overridden && (
132+
<Tooltip label="Revert override">
133+
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onRevert(flag.key)}>
134+
<IconArrowBackUp size={16} />
135+
</ActionIcon>
136+
</Tooltip>
137+
)}
64138
{flag.variants.length === 0 ? (
65139
<Text c="dimmed" size="xs">
66140
no variants
67141
</Text>
68142
) : (
69-
flag.variants.map((variant) => (
70-
<Badge key={variant.name} variant="light" color="gray" title={formatValue(variant.value)}>
71-
{variant.name}
72-
</Badge>
73-
))
143+
flag.variants.map((variant) => {
144+
const isActive = overridden && valuesEqual(override.value, variant.value)
145+
// The catalog falls back to the raw string when a variant doesn't parse as its
146+
// declared type (see parseVariantValue) — writing that through would violate the
147+
// same contract validateOverrideValue enforces for manual overrides. `allowNull` keeps
148+
// a legitimate JSON `null` variant applyable (a raw-string type mismatch still fails).
149+
const validationError = validateOverrideValue(flag.type, variant.value, { allowNull: true })
150+
return (
151+
<Button
152+
key={variant.name}
153+
size="compact-xs"
154+
variant={isActive ? 'filled' : 'default'}
155+
color={isActive ? 'violet' : 'gray'}
156+
disabled={!!validationError}
157+
onClick={() =>
158+
onSelectVariant(flag.key, { type: flag.type, value: variant.value as FlagOverride['value'] })
159+
}
160+
title={validationError ?? formatValue(variant.value)}
161+
>
162+
{variant.name}
163+
</Button>
164+
)
165+
})
74166
)}
75167
</Group>
76168
</Group>
@@ -104,6 +196,10 @@ function FlagKey({ value }: { value: string }) {
104196
)
105197
}
106198

199+
function valuesEqual(a: unknown, b: unknown): boolean {
200+
return JSON.stringify(a) === JSON.stringify(b)
201+
}
202+
107203
function formatValue(value: unknown): string {
108204
return typeof value === 'string' ? value : JSON.stringify(value)
109205
}

developer-extension/src/panel/components/tabs/flagsTab/flagFilterBar.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { Group, MultiSelect, Stack, TagsInput, TextInput } from '@mantine/core'
22
import { IconSearch } from '@tabler/icons-react'
33
import React from 'react'
4-
import { FLAG_TYPES, TYPE_LABELS } from './flagTypeConstants'
5-
import type { FlagCatalogView } from './useFlagCatalogView'
4+
import { FLAG_TYPES, FLAG_TYPE_CONFIG } from './flagTypes'
5+
import { useFlagsContext } from './flagsContext'
66

77
// Type is a fixed set, so its options are static. There's no tags endpoint and we only load a page
88
// at a time, so the Tag filter can't show every tag — instead it offers `tagSuggestions` (tags seen
99
// on pages loaded so far) as autocomplete while still accepting any typed tag. Search/type/tags are
1010
// all applied server-side (see useFlagCatalog).
11-
export function FlagFilterBar({ view, tagSuggestions }: { view: FlagCatalogView; tagSuggestions: string[] }) {
12-
const typeOptions = FLAG_TYPES.map((type) => ({ value: type, label: TYPE_LABELS[type] }))
11+
export function FlagFilterBar() {
12+
const { view, tagSuggestions } = useFlagsContext()
13+
const typeOptions = FLAG_TYPES.map((type) => ({ value: type, label: FLAG_TYPE_CONFIG[type].label }))
1314

1415
return (
1516
<Stack gap="xs">

developer-extension/src/panel/components/tabs/flagsTab/flagTypeConstants.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { validateOverrideValue, type FlagType } from './flagTypes'
2+
3+
describe('validateOverrideValue', () => {
4+
it('accepts values matching their declared type', () => {
5+
expect(validateOverrideValue('BOOLEAN', true)).toBeNull()
6+
expect(validateOverrideValue('STRING', 'hello')).toBeNull()
7+
expect(validateOverrideValue('INTEGER', 3)).toBeNull()
8+
expect(validateOverrideValue('NUMERIC', 3.14)).toBeNull()
9+
expect(validateOverrideValue('JSON', { a: 1 })).toBeNull()
10+
})
11+
12+
it('returns an error (rather than crashing) for a value_type outside the known union', () => {
13+
expect(validateOverrideValue('MYSTERY' as FlagType, 'x')).toContain('Unsupported flag type')
14+
})
15+
16+
it('rejects null by default but accepts a JSON null when allowNull is set', () => {
17+
expect(validateOverrideValue('STRING', null)).toBe('Value cannot be null')
18+
expect(validateOverrideValue('JSON', null)).toBe('Value cannot be null')
19+
// A JSON variant can legitimately be null (typeof null === 'object' matches JSON).
20+
expect(validateOverrideValue('JSON', null, { allowNull: true })).toBeNull()
21+
// allowNull still enforces the type — null isn't valid for a non-object type.
22+
expect(validateOverrideValue('BOOLEAN', null, { allowNull: true })).toContain('must be a boolean')
23+
})
24+
25+
it('rejects type mismatches', () => {
26+
expect(validateOverrideValue('BOOLEAN', 'true')).toContain('must be a boolean')
27+
expect(validateOverrideValue('STRING', 1)).toContain('must be a string')
28+
expect(validateOverrideValue('NUMERIC', 'x')).toContain('must be a number')
29+
})
30+
31+
it('rejects non-integer and unsafe INTEGER values', () => {
32+
expect(validateOverrideValue('INTEGER', 3.5)).toContain('whole number')
33+
// Beyond Number.MAX_SAFE_INTEGER: not a reliable integer, so reject it too.
34+
expect(validateOverrideValue('INTEGER', Number.MAX_SAFE_INTEGER + 1)).toContain('safe integer range')
35+
})
36+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Shared flag and override types plus the per-type value logic (labels, parsing, validation) they
2+
// drive. One home for everything that is specific to a flag's value type, imported by both the
3+
// request/catalog layer and the inspected-page override layer.
4+
5+
// Feature-flag value types, in display order (drives the catalog's Type filter).
6+
export const FLAG_TYPES = ['BOOLEAN', 'STRING', 'INTEGER', 'NUMERIC', 'JSON'] as const
7+
8+
// The value type of a feature flag (and of an override for it), derived from FLAG_TYPES so the list
9+
// stays the single source of truth.
10+
export type FlagType = (typeof FLAG_TYPES)[number]
11+
12+
interface FlagTypeConfig {
13+
// Display label matching the webapp's Type filter (NUMERIC shows as "Number").
14+
label: string
15+
// The JS `typeof` an override of this type must have (the DatadogDevtools wrapper would otherwise
16+
// throw at resolve time); used by validateOverrideValue.
17+
expectedJsType: 'boolean' | 'string' | 'number' | 'object'
18+
// Error copy shown when the manual form fails to parse input of this type. BOOLEAN never fails
19+
// (its Switch yields a real boolean), so its message is unreachable but kept for completeness.
20+
parseErrorMessage: string
21+
}
22+
23+
// Per-type display + validation metadata in one descriptor: FLAG_TYPES stays the ordered list, while
24+
// this is the single exhaustive source of everything specific to each type.
25+
export const FLAG_TYPE_CONFIG = {
26+
BOOLEAN: { label: 'Boolean', expectedJsType: 'boolean', parseErrorMessage: 'Enter true or false' },
27+
STRING: { label: 'String', expectedJsType: 'string', parseErrorMessage: 'Enter a value' },
28+
INTEGER: {
29+
label: 'Integer',
30+
expectedJsType: 'number',
31+
parseErrorMessage: 'Enter a whole number within the safe integer range',
32+
},
33+
NUMERIC: { label: 'Number', expectedJsType: 'number', parseErrorMessage: 'Enter a valid number' },
34+
JSON: { label: 'JSON', expectedJsType: 'object', parseErrorMessage: 'Enter valid JSON' },
35+
} satisfies Record<FlagType, FlagTypeConfig>
36+
37+
export type TypedParseResult = { ok: true; value: unknown } | { ok: false }
38+
39+
// Structural parsing rules for a flag value string, shared between the catalog (API variant values,
40+
// always strings, tolerant of malformed input) and the manual override form (user input, rejects
41+
// malformed input). BOOLEAN is excluded: the API sends it as the strings 'true'/'false' while the
42+
// form already gets a JS boolean from its Switch control, so there's no shared string-parsing rule
43+
// for it. Callers decide what an `{ ok: false }` result means for them (fall back vs. reject).
44+
export function parseTypedString(type: Exclude<FlagType, 'BOOLEAN'>, raw: string): TypedParseResult {
45+
switch (type) {
46+
case 'INTEGER': {
47+
// Require the whole (trimmed) string to be an integer within the safe range, so a value like
48+
// "5abc" or 9007199254740993 isn't silently rounded or truncated.
49+
const trimmed = raw.trim()
50+
const parsed = Number(trimmed)
51+
return /^[+-]?\d+$/.test(trimmed) && Number.isSafeInteger(parsed) ? { ok: true, value: parsed } : { ok: false }
52+
}
53+
case 'NUMERIC': {
54+
// Require a non-empty (trimmed) string that parses fully to a finite number — Number('') is 0
55+
// and Number(' ') is also 0, so an all-whitespace input must not be treated as valid.
56+
const trimmed = raw.trim()
57+
const parsed = Number(trimmed)
58+
return trimmed !== '' && Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false }
59+
}
60+
case 'JSON':
61+
try {
62+
return { ok: true, value: JSON.parse(raw) as unknown }
63+
} catch {
64+
return { ok: false }
65+
}
66+
case 'STRING':
67+
return { ok: true, value: raw }
68+
}
69+
}
70+
71+
/**
72+
* Validates an already-parsed override value against its declared type, returning an error message
73+
* or null if valid. This is the value-level counterpart to parseTypedString (which turns a string
74+
* into a value): the catalog's variant-click path validates the catalog value directly, and the
75+
* manual form validates after parseTypedString produces a value.
76+
*
77+
* `allowNull` accepts `null` (a valid JSON value that real flag variants can use) — the catalog
78+
* passes it so a JSON `null` variant stays applyable; the manual-entry form leaves it off so a
79+
* hand-typed empty value is still rejected. Either way a non-JSON `null` still fails the type check.
80+
*/
81+
export function validateOverrideValue(
82+
type: FlagType,
83+
value: unknown,
84+
{ allowNull = false }: { allowNull?: boolean } = {}
85+
): string | null {
86+
if (value === null && !allowNull) {
87+
return 'Value cannot be null'
88+
}
89+
const config: FlagTypeConfig | undefined = FLAG_TYPE_CONFIG[type]
90+
if (!config) {
91+
// The catalog API can return a value_type outside our union (a compile-time assumption, not a
92+
// runtime guarantee — see parseVariantValue); reject rather than crash on a missing descriptor.
93+
return `Unsupported flag type: ${type}`
94+
}
95+
if (typeof value !== config.expectedJsType) {
96+
return `Value must be a ${config.expectedJsType} for type ${type}`
97+
}
98+
if (type === 'INTEGER' && !Number.isSafeInteger(value)) {
99+
return 'INTEGER value must be a whole number within the safe integer range'
100+
}
101+
return null
102+
}

0 commit comments

Comments
 (0)