Skip to content
Open
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
5 changes: 5 additions & 0 deletions developer-extension/src/common/toErrorMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Turns an unknown caught value into a displayable string: an Error's message, or the value coerced
// to a string. One place to change how errors read across the panel.
export function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
16 changes: 6 additions & 10 deletions developer-extension/src/panel/components/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,9 @@ export function Panel() {
<Tabs.Tab value={PanelTabs.Replay}>
<Text>Live replay</Text>
</Tabs.Tab>
{settings.datadogMode && (
<Tabs.Tab value={PanelTabs.Flags}>
<Text>Feature Flags</Text>
</Tabs.Tab>
)}
<Tabs.Tab value={PanelTabs.Flags}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image In the Settings tab when we override a value we show this icon. Would it be possible to do the same here? If on the page we are, we have overriden a FF we could show the icon?

<Text>Feature Flags</Text>
</Tabs.Tab>
<Tabs.Tab
value={PanelTabs.Settings}
rightSection={
Expand Down Expand Up @@ -98,11 +96,9 @@ export function Panel() {
<Tabs.Panel value={PanelTabs.Replay} className={classes.tab}>
<ReplayTab />
</Tabs.Panel>
{settings.datadogMode && (
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
<FlagsTab />
</Tabs.Panel>
)}
<Tabs.Panel value={PanelTabs.Flags} className={classes.tab}>
<FlagsTab />
</Tabs.Panel>
<Tabs.Panel value={PanelTabs.Settings} className={classes.tab}>
<SettingsTab />
</Tabs.Panel>
Expand Down
5 changes: 5 additions & 0 deletions developer-extension/src/panel/components/tabBase.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

.topContainer {
margin: 0;
/* Sit above the scrolling content and cast a soft shadow onto it, so a long list reads as scrolling
*under* the header instead of looking cut off at the top edge. Applies to every tab's top bar. */
position: relative;
z-index: 1;
box-shadow: 0 4px 8px -6px rgba(0, 0, 0, 0.25);
}

.leftContainer {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { Anchor, Badge, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
import React, { useState } from 'react'
import { Badge, Box, Button, Center, Group, Select, Stack, Text } from '@mantine/core'
import React from 'react'
import { useSettings } from '../../../hooks/useSettings'
import type { FlagAuthState } from './useFlagAuth'
import { FLAG_SITES } from './oauth'

export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
const [advancedOpen, setAdvancedOpen] = useState(false)

return (
<Center h="100%" className="dd-privacy-allow">
<Stack align="center" gap="md" maw={460} px="md">
<Text size="xl" fw={600} ta="center">
Authenticate with Datadog to access your feature flags
</Text>
{/* Pick the site before signing in: it selects which Datadog OAuth server + FFE API the flow
talks to (see FLAG_SITES), so it must be set before the Sign in button runs that flow. */}
<Box w="100%">
{/* Locked while signing in: the chosen site is baked into the OAuth flow already running, so
switching mid-flow would point the resulting token at a different environment. */}
<SiteField disabled={auth.connecting} />
</Box>
<Button color="violet" onClick={auth.connect} loading={auth.connecting}>
Sign in to Datadog
</Button>
Expand All @@ -21,14 +26,12 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
{auth.error}
</Text>
)}

<Anchor component="button" type="button" size="xs" c="dimmed" onClick={() => setAdvancedOpen((open) => !open)}>
{advancedOpen ? '− Hide advanced' : 'Advanced: site'}
</Anchor>
{advancedOpen && (
<Stack gap="sm" style={{ width: '100%' }}>
<SiteField />
</Stack>
{/* A revocation that failed leaves the grant live at Datadog while this panel is signed out,
so the notice belongs on this screen — it's the one the user lands on after disconnecting. */}
{auth.warning && (
<Text c="orange" size="xs" ta="center">
{auth.warning} You can revoke it from Datadog under Organization Settings → Authorized Applications.
</Text>
)}
</Stack>
</Center>
Expand All @@ -38,30 +41,39 @@ export function ConnectScreen({ auth }: { auth: FlagAuthState }) {
export function ConnectionHeader({ auth }: { auth: FlagAuthState }) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Group gap="xs">
<Badge color="green" variant="light">
Connected via OAuth
</Badge>
<Text c="dimmed" size="xs">
{auth.site}
</Text>
</Group>
<Button size="compact-xs" variant="subtle" color="gray" onClick={auth.disconnect}>
{/* One read-only status badge — "CONNECTED: <site>" — states which site you're connected to
without a separate input-looking field (the auth method is an implementation detail the user
doesn't need). Disconnect is a red button so it reads as the destructive sign-out. */}
<Group gap="xs" align="center">
<Badge color="green" variant="light">
Connected: {auth.site}
</Badge>
<Button
size="compact-xs"
variant="light"
color="red"
onClick={auth.disconnect}
loading={auth.disconnecting}
// Disconnect revokes the grant at Datadog before clearing the local session, so guard
// against a second click re-running it against tokens the first click already revoked.
disabled={auth.disconnecting}
>
Disconnect
</Button>
</Group>
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op. */}
{/* Surface disconnect failures here too — otherwise a failed Disconnect looks like a no-op.
(A revoke-succeeded-but-grant-live warning can't appear here: it always accompanies a
successful local sign-out, which flips to the ConnectScreen where the notice lives.) */}
{auth.error && (
<Text c="red" size="xs" ta="right">
<Text c="red" size="xs">
{auth.error}
</Text>
)}
</Stack>
)
}

function SiteField() {
function SiteField({ disabled }: { disabled?: boolean }) {
const [{ flagsSite }, setSetting] = useSettings()

return (
Expand All @@ -72,6 +84,7 @@ function SiteField() {
value={flagsSite}
onChange={(value) => value && setSetting('flagsSite', value)}
allowDeselect={false}
disabled={disabled}
size="xs"
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Shared helper for the FFE API calls the Flags tab makes (catalog, current user, teams). Centralizes
// the bearer-auth header + response handling that flagsRequests.ts and flagIdentity.ts would
// otherwise each repeat.

// Thrown on a 403 so callers can tell "the token lacks the scope" apart from a real failure (used by
// flagIdentity to degrade the team filter rather than fail the whole tab).
export class ForbiddenError extends Error {}

/**
* GETs a JSON resource from the FFE API with the OAuth bearer token. Throws ForbiddenError on 403 and
* a generic Error on any other non-2xx, prefixing the message with `errorLabel`. Keep customer data
* (e.g. a flag key) out of `errorLabel` — these errors are logged and the panel forwards logs to its
* own telemetry.
*/
export async function fetchFfeJson<T>(url: string, token: string, errorLabel: string): Promise<T> {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.status === 403) {
throw new ForbiddenError(`${errorLabel}: 403 ${response.statusText}`)
}
if (!response.ok) {
throw new Error(`${errorLabel}: ${response.status} ${response.statusText}`)
}
return (await response.json()) as T
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { ActionIcon, Box, Button, Code, CopyButton, Group, Loader, Space, Text, Tooltip } from '@mantine/core'
import {
ActionIcon,
Anchor,
Box,
Button,
Code,
CopyButton,
Group,
Loader,
Space,
Stack,
Text,
Tooltip,
} from '@mantine/core'
import { IconArrowBackUp, IconCopy } from '@tabler/icons-react'
import React, { type ReactNode } from 'react'
import React, { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import type { CatalogFlag } from './flagsRequests'
import { useFlagsContext } from './flagsContext'
import { validateOverrideValue } from './flagTypes'
Expand Down Expand Up @@ -29,7 +42,7 @@ export function FlagCatalogBody() {
<Space h="xs" />
<FlagList
flags={bottomFlags}
borderColor="var(--mantine-color-gray-2)"
borderColor="var(--mantine-color-default-border)"
// `bottomFlags` is the page minus overridden flags (those are pinned above). Only call it "no
// match" when the server total is 0; otherwise this page's flags are all overridden.
emptyMessage={
Expand Down Expand Up @@ -57,7 +70,7 @@ export function OverridesSection() {
Local overrides ({overriddenFlags.length})
</Text>
<Space h="xs" />
<FlagList flags={overriddenFlags} borderColor="var(--mantine-color-violet-2)" />
<FlagList flags={overriddenFlags} borderColor="var(--mantine-color-violet-outline)" />
</>
)
}
Expand Down Expand Up @@ -113,20 +126,25 @@ function FlagRow({
<Group
justify="space-between"
wrap="nowrap"
align="center"
// Top-align so the variant buttons stay up beside the name/key instead of drifting to the
// vertical middle of a long description.
align="flex-start"
px="sm"
py="xs"
py="sm"
style={{
borderBottom: '1px solid var(--mantine-color-gray-1)',
backgroundColor: overridden ? 'var(--mantine-color-violet-0)' : undefined,
borderBottom: '1px solid var(--mantine-color-default-border)',
// Mantine's scheme-aware subtle tint (same one variant="light" uses): light violet in light
// mode, a muted translucent violet in dark mode — not a full saturated fill.
backgroundColor: overridden ? 'var(--mantine-color-violet-light)' : undefined,
}}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Stack gap={6} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{flag.name}
</Text>
<FlagKey value={flag.key} />
</Box>
{flag.description && <FlagDescription description={flag.description} />}
</Stack>
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flexShrink: 0, maxWidth: '55%' }}>
{overridden && (
<Tooltip label="Revert override">
Expand Down Expand Up @@ -169,6 +187,56 @@ function FlagRow({
)
}

// Free-text descriptions can run long. Show a single line by default with a "Show more" toggle that
// expands the rest inline. The toggle only appears when the one-line clamp actually hides something —
// measured rather than guessed from length, since a short description can still wrap and a long one
// might fit.
function FlagDescription({ description }: { description: string }) {
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
const textRef = useRef<HTMLParagraphElement>(null)

// Measure whether the collapsed description overflows one line, so we know to offer "Show more".
// Skip while expanded — the clamp is off then, so a measurement would read as "fits" and wrongly
// hide the toggle; `overflowing` keeps its collapsed value. The ResizeObserver re-measures when the
// panel width changes, so narrowing the DevTools panel surfaces a newly-clamped description's toggle.
useLayoutEffect(() => {
const el = textRef.current
if (!el || expanded) {
return
}
const measure = () => setOverflowing(el.scrollHeight > el.clientHeight)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [description, expanded])

return (
// Extra top margin gives the description a touch more separation from the key above it than the
// name↔key gap, so the row reads as "title/key" then "description".
<Box mt={4}>
<Text ref={textRef} size="xs" lineClamp={expanded ? undefined : 1}>
{description}
</Text>
{overflowing && (
// Accent color in both states so it reads as the row's action. A hair smaller than the
// description text and sitting right beneath it, so the two read as clearly distinct.
<Anchor
component="button"
type="button"
fz={10}
c="violet"
onClick={() => setExpanded((value) => !value)}
style={{ display: 'inline-block', marginTop: 0 }}
>
{expanded ? 'Show less' : 'Show more'}
</Anchor>
)}
</Box>
)
}

function FlagKey({ value }: { value: string }) {
return (
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
Expand All @@ -179,14 +247,24 @@ function FlagKey({ value }: { value: string }) {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
// Negate the chip's own horizontal padding so the key text lines up with the flag name above.
paddingInline: 6,
marginLeft: -6,
Comment thread
kellyw1806 marked this conversation as resolved.
}}
>
{value}
</Code>
<CopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy key'} withArrow>
<ActionIcon size="xs" variant="subtle" color="gray" onClick={copy} style={{ flexShrink: 0 }}>
{/* Flip to violet on copy for a moment of feedback, then back to neutral grey. */}
<ActionIcon
size="xs"
variant="subtle"
color={copied ? 'violet' : 'gray'}
onClick={copy}
style={{ flexShrink: 0 }}
>
<IconCopy size={12} />
</ActionIcon>
</Tooltip>
Expand Down
Loading
Loading