Skip to content

Commit 5b4061e

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 5375214 commit 5b4061e

6 files changed

Lines changed: 527 additions & 17 deletions

File tree

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

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { ActionIcon, Badge, Box, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
2-
import { IconCopy } from '@tabler/icons-react'
1+
import { ActionIcon, Box, Button, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
2+
import { IconArrowBackUp, IconCopy } from '@tabler/icons-react'
33
import React from 'react'
4+
import type { FlagOverride, FlagOverrides } from '../../../hooks/useFlagOverrides'
5+
import { getOverride } from '../../../hooks/useFlagOverrides'
46
import type { CatalogFlag } from './flagCatalog'
57
import type { FlagCatalogState } from './useFlagCatalog'
68

@@ -9,11 +11,17 @@ export function FlagCatalogBody({
911
flags,
1012
totalFiltered,
1113
totalFlags,
14+
overrides,
15+
onSelectVariant,
16+
onRevert,
1217
}: {
1318
catalog: FlagCatalogState
1419
flags: CatalogFlag[]
1520
totalFiltered: number
1621
totalFlags: number
22+
overrides: FlagOverrides
23+
onSelectVariant: (flagKey: string, override: FlagOverride) => void
24+
onRevert: (flagKey: string) => void
1725
}) {
1826
if (catalog.loading) {
1927
return (
@@ -39,22 +47,45 @@ export function FlagCatalogBody({
3947
No flags match.
4048
</Text>
4149
) : (
42-
flags.map((flag) => <FlagRow key={flag.key} flag={flag} />)
50+
flags.map((flag) => (
51+
<FlagRow
52+
key={flag.key}
53+
flag={flag}
54+
override={getOverride(overrides, flag.key)}
55+
onSelectVariant={onSelectVariant}
56+
onRevert={onRevert}
57+
/>
58+
))
4359
)}
4460
</Box>
4561
</>
4662
)
4763
}
4864

49-
function FlagRow({ flag }: { flag: CatalogFlag }) {
65+
function FlagRow({
66+
flag,
67+
override,
68+
onSelectVariant,
69+
onRevert,
70+
}: {
71+
flag: CatalogFlag
72+
override: FlagOverride | undefined
73+
onSelectVariant: (flagKey: string, override: FlagOverride) => void
74+
onRevert: (flagKey: string) => void
75+
}) {
76+
const overridden = override !== undefined
77+
5078
return (
5179
<Group
5280
justify="space-between"
5381
wrap="nowrap"
5482
align="center"
5583
px="sm"
5684
py="xs"
57-
style={{ borderBottom: '1px solid var(--mantine-color-gray-1)' }}
85+
style={{
86+
borderBottom: '1px solid var(--mantine-color-gray-1)',
87+
backgroundColor: overridden ? 'var(--mantine-color-violet-0)' : undefined,
88+
}}
5889
>
5990
<Box style={{ minWidth: 0, flex: 1 }}>
6091
<Text size="sm" fw={600} truncate>
@@ -63,16 +94,33 @@ function FlagRow({ flag }: { flag: CatalogFlag }) {
6394
<FlagKey value={flag.key} />
6495
</Box>
6596
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
97+
{overridden && (
98+
<Tooltip label="Revert override">
99+
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onRevert(flag.key)}>
100+
<IconArrowBackUp size={16} />
101+
</ActionIcon>
102+
</Tooltip>
103+
)}
66104
{flag.variants.length === 0 ? (
67105
<Text c="dimmed" size="xs">
68106
no variants
69107
</Text>
70108
) : (
71-
flag.variants.map((variant) => (
72-
<Badge key={variant.name} variant="light" color="gray" title={formatValue(variant.value)}>
73-
{variant.name}
74-
</Badge>
75-
))
109+
flag.variants.map((variant) => {
110+
const isActive = overridden && valuesEqual(override.value, variant.value)
111+
return (
112+
<Button
113+
key={variant.name}
114+
size="compact-xs"
115+
variant={isActive ? 'filled' : 'default'}
116+
color={isActive ? 'violet' : 'gray'}
117+
onClick={() => onSelectVariant(flag.key, { type: flag.type, value: variant.value })}
118+
title={formatValue(variant.value)}
119+
>
120+
{variant.name}
121+
</Button>
122+
)
123+
})
76124
)}
77125
</Group>
78126
</Group>
@@ -106,6 +154,10 @@ function FlagKey({ value }: { value: string }) {
106154
)
107155
}
108156

157+
function valuesEqual(a: unknown, b: unknown): boolean {
158+
return JSON.stringify(a) === JSON.stringify(b)
159+
}
160+
109161
function formatValue(value: unknown): string {
110162
return typeof value === 'string' ? value : JSON.stringify(value)
111163
}

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

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,46 @@
1-
import { Box, Group, Pagination, Space } from '@mantine/core'
2-
import React from 'react'
1+
import { Alert, Anchor, Box, Button, Code, Group, Pagination, Space } from '@mantine/core'
2+
import React, { useState } from 'react'
33
import { TabBase } from '../../tabBase'
4+
import type { FlagOverride } from '../../../hooks/useFlagOverrides'
5+
import { useFlagOverrides } from '../../../hooks/useFlagOverrides'
46
import { useFlagCatalog } from './useFlagCatalog'
57
import { useFlagCatalogView } from './useFlagCatalogView'
68
import { useFlagAuth } from './useFlagAuth'
79
import { ConnectScreen, ConnectionHeader } from './connectScreen'
810
import { FlagCatalogBody } from './flagCatalogList'
911
import { FlagFilterBar } from './flagFilterBar'
12+
import { ManualOverrideForm } from './manualOverrideForm'
1013

1114
export function FlagsTab() {
1215
const auth = useFlagAuth()
16+
const { overrides, devtoolsEnabled, setOverride, clearOverride, clearAll, reloadPage } = useFlagOverrides()
1317
const catalog = useFlagCatalog(auth)
14-
const view = useFlagCatalogView(catalog.flags)
18+
const view = useFlagCatalogView(catalog.flags, overrides)
19+
20+
const [pendingReload, setPendingReload] = useState(false)
21+
const [addOpen, setAddOpen] = useState(false)
22+
23+
function applyOverride(flagKey: string, override: FlagOverride) {
24+
setPendingReload(true)
25+
void setOverride(flagKey, override)
26+
}
27+
28+
function removeOverride(flagKey: string) {
29+
setPendingReload(true)
30+
void clearOverride(flagKey)
31+
}
32+
33+
function removeAll() {
34+
setPendingReload(true)
35+
void clearAll()
36+
}
37+
38+
function reload() {
39+
reloadPage()
40+
setPendingReload(false)
41+
}
42+
43+
const overrideCount = Object.keys(overrides).length
1544

1645
// Gate the whole tab: nothing shows until the user connects via OAuth.
1746
if (!auth.isConnected) {
@@ -33,11 +62,25 @@ export function FlagsTab() {
3362
}
3463
>
3564
<Box px="md" py="sm" className="dd-privacy-allow">
65+
{!devtoolsEnabled && (
66+
<>
67+
<Alert color="orange" title="DatadogDevtools not detected">
68+
The <Code>DatadogDevtools</Code> provider wrapper was not detected on this page. Overrides will only take
69+
effect once the page composes it. You can still set overrides — they'll apply when the wrapper is in
70+
place.
71+
</Alert>
72+
<Space h="sm" />
73+
</>
74+
)}
75+
3676
<FlagCatalogBody
3777
catalog={catalog}
3878
flags={view.paginated}
3979
totalFiltered={view.filteredCount}
4080
totalFlags={catalog.flags.length}
81+
overrides={overrides}
82+
onSelectVariant={applyOverride}
83+
onRevert={removeOverride}
4184
/>
4285

4386
{view.totalPages > 1 && (
@@ -48,6 +91,27 @@ export function FlagsTab() {
4891
</Group>
4992
</>
5093
)}
94+
95+
<Space h="md" />
96+
<Group justify="space-between">
97+
<Button size="xs" variant="light" color="red" onClick={removeAll} disabled={overrideCount === 0}>
98+
Clear all{overrideCount > 0 ? ` (${overrideCount})` : ''}
99+
</Button>
100+
<Button color="violet" onClick={reload} disabled={!pendingReload}>
101+
Save Overrides and Refresh Page
102+
</Button>
103+
</Group>
104+
105+
<Space h="md" />
106+
<Anchor size="xs" c="dimmed" onClick={() => setAddOpen((open) => !open)}>
107+
{addOpen ? '− Hide custom override' : '+ Add a custom override'}
108+
</Anchor>
109+
{addOpen && (
110+
<>
111+
<Space h="sm" />
112+
<ManualOverrideForm onApply={applyOverride} />
113+
</>
114+
)}
51115
</Box>
52116
</TabBase>
53117
)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Box, Button, Group, JsonInput, SegmentedControl, Space, Stack, Switch, Text, TextInput } from '@mantine/core'
2+
import React, { useState } from 'react'
3+
import type { FlagOverride } from '../../../hooks/useFlagOverrides'
4+
import { validateOverrideValue } from '../../../hooks/useFlagOverrides'
5+
import { FLAG_TYPES, type FlagOverrideType } from './flagTypeConstants'
6+
7+
export function ManualOverrideForm({ onApply }: { onApply: (flagKey: string, override: FlagOverride) => void }) {
8+
const [flagKey, setFlagKey] = useState('')
9+
const [type, setType] = useState<FlagOverrideType>('BOOLEAN')
10+
const [booleanValue, setBooleanValue] = useState(true)
11+
const [textValue, setTextValue] = useState('')
12+
const [error, setError] = useState<string | null>(null)
13+
14+
function submit() {
15+
setError(null)
16+
if (!flagKey.trim()) {
17+
setError('Flag key is required')
18+
return
19+
}
20+
let value: FlagOverride['value']
21+
try {
22+
value = parseFormValue(type, type === 'BOOLEAN' ? booleanValue : textValue)
23+
} catch (err) {
24+
setError(err instanceof Error ? err.message : String(err))
25+
return
26+
}
27+
const validationError = validateOverrideValue(type, value)
28+
if (validationError) {
29+
setError(validationError)
30+
return
31+
}
32+
onApply(flagKey.trim(), { type, value })
33+
setError(null)
34+
}
35+
36+
return (
37+
<Stack gap="sm" maw={420}>
38+
<TextInput
39+
label="Flag key"
40+
placeholder="my-flag"
41+
value={flagKey}
42+
onChange={(event) => setFlagKey(event.currentTarget.value)}
43+
size="xs"
44+
/>
45+
<Box>
46+
<Text size="xs" fw={500}>
47+
Type
48+
</Text>
49+
<Space h={4} />
50+
<SegmentedControl
51+
color="violet"
52+
size="xs"
53+
value={type}
54+
onChange={(value) => setType(value)}
55+
data={FLAG_TYPES.map((flagType) => ({ value: flagType, label: flagType }))}
56+
/>
57+
</Box>
58+
59+
{type === 'BOOLEAN' ? (
60+
<Switch
61+
label={booleanValue ? 'true' : 'false'}
62+
checked={booleanValue}
63+
onChange={(event) => setBooleanValue(event.currentTarget.checked)}
64+
color="violet"
65+
/>
66+
) : type === 'JSON' ? (
67+
<JsonInput label="Value (JSON)" value={textValue} onChange={setTextValue} autosize minRows={2} size="xs" />
68+
) : (
69+
<TextInput
70+
label="Value"
71+
placeholder={type === 'STRING' ? 'text' : 'number'}
72+
value={textValue}
73+
onChange={(event) => setTextValue(event.currentTarget.value)}
74+
size="xs"
75+
/>
76+
)}
77+
78+
{error && (
79+
<Text c="red" size="xs">
80+
{error}
81+
</Text>
82+
)}
83+
84+
<Group justify="flex-end">
85+
<Button size="xs" color="violet" onClick={submit}>
86+
Apply override
87+
</Button>
88+
</Group>
89+
</Stack>
90+
)
91+
}
92+
93+
function parseFormValue(type: FlagOverrideType, raw: boolean | string): FlagOverride['value'] {
94+
switch (type) {
95+
case 'BOOLEAN':
96+
return Boolean(raw)
97+
case 'STRING':
98+
return String(raw)
99+
case 'INTEGER':
100+
case 'NUMERIC': {
101+
const text = String(raw).trim()
102+
if (text === '' || Number.isNaN(Number(text))) {
103+
throw new Error('Enter a valid number')
104+
}
105+
return Number(text)
106+
}
107+
case 'JSON':
108+
try {
109+
return JSON.parse(String(raw)) as object
110+
} catch {
111+
throw new Error('Enter valid JSON')
112+
}
113+
}
114+
}

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { useMemo, useState } from 'react'
2+
import type { FlagOverrides } from '../../../hooks/useFlagOverrides'
3+
import { getOverride } from '../../../hooks/useFlagOverrides'
24
import type { CatalogFlag } from './flagCatalog'
35

46
const CATALOG_PAGE_SIZE = 20
@@ -18,9 +20,10 @@ export interface FlagCatalogView {
1820
}
1921

2022
/**
21-
* Owns the catalog's search/filter/pagination state, keeping FlagsTab a thin composer.
23+
* Owns the catalog's search/filter/sort/pagination state, keeping FlagsTab a thin composer.
24+
* Flags with an active override float to the top so they're easy to spot.
2225
*/
23-
export function useFlagCatalogView(flags: CatalogFlag[]): FlagCatalogView {
26+
export function useFlagCatalogView(flags: CatalogFlag[], overrides: FlagOverrides): FlagCatalogView {
2427
const [search, setSearchState] = useState('')
2528
const [typeFilter, setTypeFilterState] = useState<string[]>([])
2629
const [tagFilter, setTagFilterState] = useState<string[]>([])
@@ -47,9 +50,24 @@ export function useFlagCatalogView(flags: CatalogFlag[]): FlagCatalogView {
4750
[flags, search, typeFilter, tagFilter]
4851
)
4952

50-
const totalPages = Math.max(1, Math.ceil(filtered.length / CATALOG_PAGE_SIZE))
53+
// Float overridden flags to the top. Memoized on filtered + overrides, so it recomputes exactly
54+
// when either input changes.
55+
const sorted = useMemo(() => {
56+
const withOverride: CatalogFlag[] = []
57+
const rest: CatalogFlag[] = []
58+
for (const flag of filtered) {
59+
if (getOverride(overrides, flag.key)) {
60+
withOverride.push(flag)
61+
} else {
62+
rest.push(flag)
63+
}
64+
}
65+
return [...withOverride, ...rest]
66+
}, [filtered, overrides])
67+
68+
const totalPages = Math.max(1, Math.ceil(sorted.length / CATALOG_PAGE_SIZE))
5169
const currentPage = Math.min(page, totalPages)
52-
const paginated = filtered.slice((currentPage - 1) * CATALOG_PAGE_SIZE, currentPage * CATALOG_PAGE_SIZE)
70+
const paginated = sorted.slice((currentPage - 1) * CATALOG_PAGE_SIZE, currentPage * CATALOG_PAGE_SIZE)
5371

5472
// Any filter change resets to the first page so results aren't hidden on a now-out-of-range page.
5573
return {

0 commit comments

Comments
 (0)