Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Alert, Badge, Box, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
import React from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { toErrorMessage } from '../../../../common/toErrorMessage'
import { useSettings } from '../../../hooks/useSettings'
import type { FlagAuthState } from './useFlagAuth'
import { useInspectedPageOverrides } from './useInspectedPageOverrides'
Expand Down Expand Up @@ -41,17 +42,64 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
}

/**
* Surfaces overrides already stored on the inspected page while signed out — otherwise this screen is
* all that renders, so an override left from an earlier session keeps affecting the page with nothing
* to explain it. Informational only; everything that mutates overrides lives on the connected tab.
* Surfaces overrides already stored on the inspected page while signed out, with a Clear all so they
* can be wiped without signing in.
*
* Mounted only while disconnected, so its navigation listeners never run alongside the connected
* tab's own instance of this hook.
*/
function DisconnectedOverridesNotice() {
const { status, overrides } = useInspectedPageOverrides()
const { status, overrides, clearAll, reloadPage } = useInspectedPageOverrides()
const [phase, setPhase] = useState<'idle' | 'confirming' | 'clearing' | 'cleared'>('idle')
const [error, setError] = useState<string | null>(null)
// Bumped on navigation so a clear that settles afterwards can't report on the page it left.
const generation = useRef(0)
const count = Object.keys(overrides).length

// A navigation brings a fresh set of overrides: a pending confirmation would clear those instead,
// and a previous "cleared" no longer describes the page.
useEffect(() => {
if (status === 'loading') {
generation.current += 1
setPhase('idle')
setError(null)
}
}, [status])

const handleClearAll = () => {
const started = generation.current
setPhase('clearing')
setError(null)
void clearAll()
.then(() => {
if (started === generation.current) {
setPhase('cleared')
}
})
.catch((err: unknown) => {
if (started === generation.current) {
setError(toErrorMessage(err))
setPhase('idle')
}
})
}

// Gated on the page agreeing it has none: a same-document SPA navigation fires no webNavigation
// event, so without this the banner could outlive the state it describes.
if (phase === 'cleared' && status === 'ready' && count === 0) {
// The page keeps applying them until it reloads and rebuilds its provider.
return (
<Alert color="green" w="100%" title="Overrides cleared">
<Group gap="xs">
<Text size="xs">Please reload the page to stop applying them.</Text>
<Button size="compact-xs" variant="light" color="green" onClick={reloadPage}>
Reload page
</Button>
</Group>
</Alert>
)
}

if (status !== 'ready' || count === 0) {
return null
}
Expand All @@ -63,11 +111,42 @@ function DisconnectedOverridesNotice() {
color="orange"
w="100%"
data-dd-privacy="mask"
title={`${count} override${count === 1 ? '' : 's'} active on this page`}
title={`${count} override${count === 1 ? '' : 's'} stored for this page`}
>
<Text size="xs">
These are stored in the page and keep applying while you are signed out. Sign in to view and remove them.
These stay in the page and may still be applying while you are signed out. Sign in to review and remove them
individually.
</Text>
<Group gap="xs" mt="xs">
{phase === 'confirming' || phase === 'clearing' ? (
<>
<Text size="xs" fw={600}>
Clear all overrides on this page, including any saved for other Datadog sites?
</Text>
<Button size="compact-xs" color="red" onClick={handleClearAll} loading={phase === 'clearing'}>
Clear
</Button>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={() => setPhase('idle')}
disabled={phase === 'clearing'}
>
Cancel
</Button>
</>
) : (
<Button size="compact-xs" variant="light" color="red" onClick={() => setPhase('confirming')}>
Clear all
</Button>
)}
</Group>
{error && (
<Text c="red" size="xs" mt="xs">
Could not clear the overrides: {error}
</Text>
)}
</Alert>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { IconArrowBackUp, IconCopy } from '@tabler/icons-react'
import React, { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import type { CatalogFlag } from './flagsRequests'
import { useFlagsContext } from './flagsContext'
import { validateOverrideValue } from './flagTypes'
import { flagTypeLabel, validateOverrideValue } from './flagTypes'
import { getOverride, type FlagOverride } from './inspectedPageFlags'

export function FlagCatalogBody() {
Expand Down Expand Up @@ -121,6 +121,8 @@ function FlagRow({
onRevert: (flagKey: string) => void
}) {
const overridden = override !== undefined
// The wrapper rejects a mismatched type, so this override won't apply.
const typeMismatch = overridden && override.type !== flag.type
Comment thread
kellyw1806 marked this conversation as resolved.

return (
<Group
Expand All @@ -132,7 +134,11 @@ function FlagRow({
py="sm"
style={{
borderBottom: '1px solid var(--mantine-color-default-border)',
backgroundColor: overridden ? 'var(--mantine-color-violet-light)' : undefined,
backgroundColor: typeMismatch
? 'var(--mantine-color-red-light)'
: overridden
? 'var(--mantine-color-violet-light)'
: undefined,
}}
>
<Stack gap={6} style={{ minWidth: 0, flex: 1 }}>
Expand All @@ -141,6 +147,18 @@ function FlagRow({
</Text>
<FlagKey value={flag.key} />
{flag.description && <FlagDescription description={flag.description} />}
{override && typeMismatch && (
<Text size="xs" c="red" fw={600}>
Type mismatch: stored as {flagTypeLabel(override.type)}, but this flag is {flagTypeLabel(flag.type)}. It
Comment thread
kellyw1806 marked this conversation as resolved.
won&apos;t apply until you clear it.
</Text>
)}
{/* Not an error: the override still works, the flag just left the catalog. */}
{flag.unresolved && (
<Text size="xs" c="dimmed">
No active flag with this key on this site — it may have been archived or deleted.
</Text>
)}
</Stack>
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
{overridden && (
Expand All @@ -156,7 +174,7 @@ function FlagRow({
</Text>
) : (
flag.variants.map((variant) => {
const isActive = overridden && valuesEqual(override.value, variant.value)
const isActive = overridden && !typeMismatch && valuesEqual(override.value, variant.value)
// The catalog keeps an unparseable variant as its raw string (see parseVariantValue), and
// writing that through would break the override type contract. `allowNull` keeps a
// legitimate JSON `null` variant applyable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ describe('flagTypeLabel', () => {
// An API value_type we don't model yet must not crash — fall back to the raw type.
expect(flagTypeLabel('MYSTERY' as FlagType)).toBe('MYSTERY')
})

it('always returns a string, so a malformed stored type cannot crash the row rendering it', () => {
expect(flagTypeLabel({} as unknown as FlagType)).toBe('[object Object]')
expect(flagTypeLabel(undefined as unknown as FlagType)).toBe('undefined')
// Finds Object.prototype.toString rather than a config, so `label` is what must be checked.
expect(flagTypeLabel('toString' as FlagType)).toBe('toString')
})
})

describe('validateOverrideValue', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,13 @@ export function validateOverrideValue(
return null
}

/** Display label for a flag type, falling back to the raw type for one we don't model yet. */
/**
* Display label for a flag type, falling back to the raw type for one we don't model yet. Always a
* string: sanitizeOverrides keeps a shaped-but-malformed override so it stays removable, so `type`
* can be a non-string, and returning that would crash the row rendering it. `label` is checked
* rather than `config` because a type named `toString` or `constructor` finds an inherited member.
*/
export function flagTypeLabel(type: FlagType): string {
const config: FlagTypeConfig | undefined = FLAG_TYPE_CONFIG[type]
return config ? config.label : type
return typeof config?.label === 'string' ? config.label : String(type)
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export interface FlagsContextValue {
totalPages: number
/** Whether a refresh is needed/in flight to (re)apply overrides, and the last mutation failure. */
pendingReload: boolean
/** The page is still applying another site's overrides until it reloads. */
siteSwitchNeedsReload: boolean
/** Set when scoping to the connected site failed, so the page may be applying another site's. */
scopeError: string | null
writesInFlight: number
mutationError: string | null
applyOverride: (flagKey: string, override: FlagOverride) => void
Expand Down Expand Up @@ -56,8 +60,18 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
const view = useFlagCatalogView(identity.userId)
const catalog = useFlagCatalog(auth, view.request)
const { setPage } = view
const { status, error, overrides, devtoolsEnabled, setOverride, clearOverride, clearAll, reloadPage } =
useInspectedPageOverrides()
const {
status,
error,
overrides,
devtoolsEnabled,
setOverride,
clearOverride,
clearAll,
reloadPage,
siteSwitchNeedsReload,
scopeError,
} = useInspectedPageOverrides(auth.site)

const totalPages = Math.max(1, Math.ceil(catalog.total / view.pageSize))

Expand All @@ -72,7 +86,7 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
// Fetched by key so an overridden flag shows regardless of which catalog page it's on, with a
// minimal row as fallback for a key that no longer resolves (so it can still be reverted).
const overrideKeys = useMemo(() => Object.keys(overrides), [overrides])
const overriddenCatalogFlags = useOverriddenFlags(auth, overrideKeys)
const { flags: overriddenCatalogFlags, missingKeys } = useOverriddenFlags(auth, overrideKeys)
const overriddenFlags = useMemo<CatalogFlag[]>(
() =>
overrideKeys.map(
Expand All @@ -84,9 +98,10 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
type: overrides[key].type,
variants: [],
tags: [],
unresolved: missingKeys.has(key),
}
),
[overrideKeys, overriddenCatalogFlags, overrides]
[overrideKeys, overriddenCatalogFlags, missingKeys, overrides]
)
// Dropped from the paginated list so they don't show twice.
const bottomFlags = useMemo(
Expand Down Expand Up @@ -159,6 +174,8 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
tagSuggestions,
totalPages,
pendingReload,
siteSwitchNeedsReload,
scopeError,
writesInFlight,
mutationError,
applyOverride,
Expand All @@ -179,6 +196,8 @@ export function FlagsProvider({ auth, children }: { auth: FlagAuthState; childre
tagSuggestions,
totalPages,
pendingReload,
siteSwitchNeedsReload,
scopeError,
writesInFlight,
mutationError,
applyOverride,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,22 +247,35 @@ describe('flagsRequests', () => {
return Promise.resolve(new Response(JSON.stringify({ data })))
})

const flags = await fetchFlagsByKeys('tok', 'datad0g.com', ['flag-a', 'missing', 'flag-b'])
const { flags, missingKeys } = await fetchFlagsByKeys('tok', 'datad0g.com', ['flag-a', 'missing', 'flag-b'])

expect(spy).toHaveBeenCalledTimes(3)
const firstUrl = new URL(spy.calls.argsFor(0)[0] as string)
expect(firstUrl.searchParams.get('key')).toBe('flag-a')
// Active-only: an archived flag sharing the key would win the dedupe and describe the override
// against the wrong type and variants.
expect(firstUrl.searchParams.get('is_archived')).toBe('false')
expect(flags.map((flag) => flag.key)).toEqual(['flag-a', 'flag-b'])
expect(missingKeys).toEqual(['missing'])
})

it('does not call a key missing when the response body has no data array', async () => {
// A 2xx that isn't the expected envelope (proxy interstitial, schema change) is tolerated so
// the section still renders, but it is not evidence the flag is gone.
spyOn(globalThis, 'fetch').and.returnValue(Promise.resolve(new Response(JSON.stringify({ errors: ['x'] }))))

const { flags, missingKeys } = await fetchFlagsByKeys('tok', 'datad0g.com', ['flag-a'])
expect(flags).toEqual([])
expect(missingKeys).toEqual([])
})

it('makes no request and returns nothing for an empty key list', async () => {
const spy = spyOn(globalThis, 'fetch')
expect(await fetchFlagsByKeys('tok', 'datad0g.com', [])).toEqual([])
expect(await fetchFlagsByKeys('tok', 'datad0g.com', [])).toEqual({ flags: [], missingKeys: [] })
expect(spy).not.toHaveBeenCalled()
})

it('drops a key whose request fails but keeps the ones that resolve', async () => {
it('drops a key whose request fails, keeps the ones that resolve, and does not call it missing', async () => {
spyOn(globalThis, 'fetch').and.callFake((input) => {
const key = new URL(input as string).searchParams.get('key')
if (key === 'boom') {
Expand All @@ -273,8 +286,9 @@ describe('flagsRequests', () => {
)
})

const flags = await fetchFlagsByKeys('tok', 'datad0g.com', ['flag-a', 'boom', 'flag-b'])
const { flags, missingKeys } = await fetchFlagsByKeys('tok', 'datad0g.com', ['flag-a', 'boom', 'flag-b'])
expect(flags.map((flag) => flag.key)).toEqual(['flag-a', 'flag-b'])
expect(missingKeys).toEqual([])
})
})
})
Loading
Loading