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
146 changes: 146 additions & 0 deletions static/app/components/emptyMessage.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
title: EmptyMessage
description: A component for displaying empty states with optional icon, title, description, and action buttons. All text content uses balanced text wrapping for better readability.
source: 'sentry/components/emptyMessage'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/emptyMessage.tsx
---

import {Button} from 'sentry/components/core/button';
import EmptyMessage from 'sentry/components/emptyMessage';
import Panel from 'sentry/components/panels/panel';
import {IconSearch} from 'sentry/icons';
import * as Storybook from 'sentry/stories';

To create a basic empty message, wrap your message in an `<EmptyMessage>` component.

```jsx
<Panel>
<EmptyMessage>
This is a simple empty message with default styling and balanced text wrapping for
better readability.
</EmptyMessage>
</Panel>
```

## Props

- **title** (`React.ReactNode`) - The title of the empty message
- **icon** (`React.ReactNode`) - Optional icon element
- **action** (`React.ReactElement`) - Optional action button
- **size** (`"md" | "lg"`) - Size of the text (default: md)
- **children** (`React.ReactNode`) - Main content text (automatically uses balanced text wrapping)

## Examples

### With Title

Add a title to provide context for the empty state.

<Storybook.Demo>
<Panel>
<EmptyMessage title="No Results Found">
We couldn't find any results matching your search criteria. Try adjusting your
filters or search terms.
</EmptyMessage>
</Panel>
</Storybook.Demo>
```jsx
<EmptyMessage title="No Results Found">
We couldn't find any results matching your search criteria. Try adjusting your filters
or search terms.
</EmptyMessage>
```

### With Icon

Add an icon to make the empty state more visually distinctive. Icons are defaulted to `lg` size, so there is typically no need to specify an icon size.

<Storybook.Demo>
<Panel>
<EmptyMessage icon={<IconSearch />} title="No Results">
We couldn't find any results matching your search criteria.
</EmptyMessage>
</Panel>
</Storybook.Demo>
```jsx
<EmptyMessage icon={<IconSearch />} title="No Results">
We couldn't find any results matching your search criteria.
</EmptyMessage>
```

### With Action

Add action buttons to guide users toward resolving the empty state.

<Storybook.Demo>
<Panel>
<EmptyMessage
icon={<IconSearch />}
title="No Results Found"
action={<Button priority="primary">Clear Filters</Button>}
>
We couldn't find any results matching your search criteria. Try adjusting your
filters or search terms.
</EmptyMessage>
</Panel>
</Storybook.Demo>
```jsx
<EmptyMessage
icon={<IconSearch />}
title="No Results Found"
action={<Button priority="primary">Clear Filters</Button>}
>
We couldn't find any results matching your search criteria. Try adjusting your filters
or search terms.
</EmptyMessage>
```

### Sizes

EmptyMessage supports two sizes: `md` (default) and `lg`.

<Storybook.Demo>
<div style={{display: 'flex', gap: '1rem'}}>
<Panel style={{flex: 1}}>
<EmptyMessage icon={<IconSearch />} title="Medium Size">
This is the default medium size empty message.
</EmptyMessage>
</Panel>
<Panel style={{flex: 1}}>
<EmptyMessage size="lg" icon={<IconSearch />} title="Large Size">
This is a large size empty message with bigger text.
</EmptyMessage>
</Panel>
</div>
</Storybook.Demo>
```jsx
<EmptyMessage size="medium" icon={<IconSearch />} title="Medium Size">
This is the default medium size empty message.
</EmptyMessage>

<EmptyMessage size="lg" icon={<IconSearch />} title="Large Size">
This is a large size empty message with bigger text.
</EmptyMessage>
```

### Text Wrapping

All text content automatically uses CSS text-wrap balance mode for better readability, especially with longer messages.

<Storybook.Demo>
<Panel>
<EmptyMessage icon={<IconSearch />} title="Text Wrapping">
Sure you haven't misspelled? If you're using a lesser-known platform, consider
choosing a more generic SDK like Browser JavaScript, Python, Node, .NET & Java or
create a generic project.
</EmptyMessage>
</Panel>
</Storybook.Demo>
```jsx
<EmptyMessage icon={<IconSearch />} title="Text Wrapping">
Sure you haven't misspelled? If you're using a lesser-known platform, consider choosing
a more generic SDK like Browser JavaScript, Python, Node, .NET & Java or create a
generic project.
</EmptyMessage>
```
107 changes: 42 additions & 65 deletions static/app/components/emptyMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,51 @@
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import {useTheme} from '@emotion/react';
import {mergeProps} from '@react-aria/utils';

import {space} from 'sentry/styles/space';
import TextBlock from 'sentry/views/settings/components/text/textBlock';
import {Container, Flex} from 'sentry/components/core/layout';
import {Text} from 'sentry/components/core/text';
import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';

interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
action?: React.ReactElement;
description?: React.ReactNode;
action?: React.ReactNode;
icon?: React.ReactNode;
leftAligned?: boolean;
size?: 'large' | 'medium';
size?: 'lg' | 'md';
title?: React.ReactNode;
}

type WrapperProps = Pick<Props, 'size'>;

const EmptyMessage = styled(
({
title,
description,
icon,
children,
action,
leftAligned: _leftAligned,
...props
}: Props) => (
<div data-test-id="empty-message" {...props}>
{icon && <IconWrapper>{icon}</IconWrapper>}
{title && <Title noMargin={!description && !children && !action}>{title}</Title>}
{description && <Description>{description}</Description>}
{children && <Description noMargin>{children}</Description>}
{action && <Action>{action}</Action>}
</div>
)
)<WrapperProps>`
display: flex;
${p =>
p.leftAligned
? css`
max-width: 70%;
align-items: flex-start;
padding: ${space(4)};
`
: css`
text-align: center;
align-items: center;
padding: ${space(4)} 15%;
`};
flex-direction: column;
color: ${p => p.theme.textColor};
font-size: ${p =>
p.size && p.size === 'large' ? p.theme.fontSize.xl : p.theme.fontSize.md};
`;

const IconWrapper = styled('div')`
color: ${p => (p.theme.isChonk ? p.theme.gray400 : p.theme.gray200)};
margin-bottom: ${space(1)};
`;

const Title = styled('strong')<{noMargin: boolean}>`
font-size: ${p => p.theme.fontSize.xl};
${p => !p.noMargin && `margin-bottom: ${space(1)};`}
`;

const Description = styled(TextBlock)`
margin: 0;
`;

const Action = styled('div')`
margin-top: ${space(2)};
`;
function EmptyMessage({title, icon, children, action, size, ...props}: Props) {
const theme = useTheme();

return (
<Flex gap="xl" direction="column" padding="3xl">
{stackProps => (
<Text
align="center"
size={size}
data-test-id="empty-message"
{...mergeProps(stackProps, props)}
>
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Invalid HTML nesting of div inside span

The EmptyMessage component uses Text as its root element, which defaults to rendering a span. However, it contains Container children (for the icon and action) that render as divs. Nesting a div inside a span is invalid HTML and can cause hydration mismatches or layout issues. The Text component should use the as="div" prop to render a block element.

Fix in Cursor Fix in Web

{icon && (
<IconDefaultsProvider size="xl">
<Container color={theme.isChonk ? theme.gray400 : theme.gray200}>
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Icon color prop has no effect

The color prop passed to Container won't apply any styling because Container doesn't support a color prop for styling purposes. The prop will be rendered as an HTML attribute but won't affect the icon's color, causing icons to display with default colors instead of the intended gray tones.

Fix in Cursor Fix in Web

{icon}
</Container>
</IconDefaultsProvider>
)}
{title && (
<Text bold size="xl" density="comfortable">
{title}
</Text>
)}
{children && (
<Text textWrap="balance" density="comfortable">
{children}
</Text>
)}
{action && <Container paddingTop="xl">{action}</Container>}
</Text>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Invalid HTML nesting of div inside span

The EmptyMessage uses Text as its root element, which defaults to rendering a span. However, its children (icon, action) use Container, which renders a div. Nesting block-level elements (div) inside inline elements (span) is invalid HTML and can cause hydration or layout issues. The root Text should be rendered as a block element (e.g., as="div").

Fix in Cursor Fix in Web

</Flex>
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Flex layout broken by Text display override

The Flex component's render prop passes display: flex styling via className to the Text component, but the Text component sets display: inline-block because of the align="center" prop. This causes a CSS conflict where the Text's display property likely overrides the Flex's display property, breaking the flexbox layout. The intended vertical stacking with gaps between icon, title, children, and action elements won't work correctly.

Fix in Cursor Fix in Web

);
}

export default EmptyMessage;
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function StackTrace({
return (
<Panel dashedBorder>
<EmptyMessage
icon={<IconWarning size="xl" />}
icon={<IconWarning />}
title={t('No app only stack trace has been found!')}
/>
</Panel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';

const FeedbackEmptyDetails = styled((props: any) => (
<FluidHeight {...props}>
<StyledEmptyMessage
icon={<IconMail size="xl" />}
description={t('No feedback selected')}
/>
<StyledEmptyMessage icon={<IconMail />}>
{t('No feedback selected')}
</StyledEmptyMessage>
</FluidHeight>
))`
display: grid;
Expand Down
2 changes: 1 addition & 1 deletion static/app/components/platformPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ function PlatformPicker({
</PlatformList>
{platformList.length === 0 && (
<EmptyMessage
icon={<IconProject size="xl" />}
icon={<IconProject />}
title={t("We don't have an SDK for that yet!")}
>
{tct(
Expand Down
11 changes: 5 additions & 6 deletions static/app/components/projects/missingProjectMembership.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,8 @@ class MissingProjectMembership extends Component<Props, State> {
<StyledPanel>
{teams.length ? (
<EmptyMessage
icon={<IconFlag size="xl" />}
icon={<IconFlag />}
title={t("You're not a member of this project.")}
description={t(
`You'll need to join a team with access before you can view this data.`
)}
action={
<Field>
<StyledSelectControl
Expand All @@ -170,9 +167,11 @@ class MissingProjectMembership extends Component<Props, State> {
)}
</Field>
}
/>
>
{t(`You'll need to join a team with access before you can view this data.`)}
</EmptyMessage>
) : (
<EmptyMessage icon={<IconFlag size="xl" />}>
<EmptyMessage icon={<IconFlag />}>
{t(
'No teams have access to this project yet. Ask an admin to add your team to this project.'
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export function ServerTree() {
{treeRequest.isLoading ? (
<LoadingIndicator />
) : hasData ? null : (
<EmptyMessage size="large" icon={<IconSearch size="xl" />}>
<EmptyMessage size="lg" icon={<IconSearch />}>
{t('No results found')}
</EmptyMessage>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function PlatformInsightsTable<DataRow extends Record<string, any>>({
columnSortBy={[]}
minimumColWidth={COL_WIDTH_MINIMUM}
emptyMessage={
<EmptyMessage size="large" icon={<IconSearch size="xl" />}>
<EmptyMessage size="lg" icon={<IconSearch />}>
{t('No results found')}
</EmptyMessage>
}
Expand Down
7 changes: 4 additions & 3 deletions static/app/views/organizationStats/usageTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ class UsageTable extends Component<Props> {
if (errorMessage.projectStats.responseJSON.detail === 'No projects available') {
return (
<EmptyMessage
icon={<IconWarning color="gray300" legacySize="48px" />}
icon={<IconWarning />}
title={t(
"You don't have access to any projects, or your organization has no projects."
)}
description={tct('Learn more about [link:Project Access]', {
>
{tct('Learn more about [link:Project Access]', {
link: <ExternalLink href={DOCS_URL} />,
})}
/>
</EmptyMessage>
);
}
return <IconWarning color="gray300" legacySize="48px" />;
Expand Down
18 changes: 10 additions & 8 deletions static/app/views/releases/detail/commitsAndFiles/emptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ export function NoReleaseRepos() {
<Main width="full">
<Panel dashedBorder>
<EmptyMessage
icon={<IconCommit size="xl" />}
icon={<IconCommit />}
title={t('Releases are better with commit data!')}
description={t('No commits associated with this release have been found.')}
/>
>
{t('No commits associated with this release have been found.')}
</EmptyMessage>
</Panel>
</Main>
</Body>
Expand All @@ -45,17 +46,18 @@ export function NoRepositories({orgSlug}: {orgSlug: string}) {
<Main width="full">
<Panel dashedBorder>
<EmptyMessage
icon={<IconCommit size="xl" />}
icon={<IconCommit />}
title={t('Releases are better with commit data!')}
description={t(
'Connect a repository to see commit info, files changed, and authors involved in future releases.'
)}
action={
<LinkButton priority="primary" to={`/settings/${orgSlug}/repos/`}>
{t('Connect a repository')}
</LinkButton>
}
/>
>
{t(
'Connect a repository to see commit info, files changed, and authors involved in future releases.'
)}
</EmptyMessage>
</Panel>
</Main>
</Body>
Expand Down
Loading
Loading