Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboards): BigNumberWidget Layout, UI, Data, State #77877

Merged
merged 19 commits into from
Sep 25, 2024
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
@@ -0,0 +1,95 @@
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';

import {BigNumberWidget} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidget';

describe('BigNumberWidget', () => {
describe('Layout', () => {
Copy link
Member

Choose a reason for hiding this comment

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

A small nit, this block is defines "Layout" stuff, but we can't really test that in RTL right? Shouldn't this just go under the "Visualization" block as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh good question! Here "Layout" means the "Layout" feature in the spec. The Jest file is organized by the widget specification features, but "Layout" is one of the more ambiguous ones

it('Renders', () => {
render(
<BigNumberWidget
title="EPS"
description="Number of events per second"
data={[
{
'eps()': 0.01087819860850493,
},
]}
meta={{
fields: {
'eps()': 'rate',
},
units: {
'eps()': '1/second',
},
}}
/>
);

expect(screen.getByText('EPS')).toBeInTheDocument();
expect(screen.getByText('Number of events per second')).toBeInTheDocument();
expect(screen.getByText('0.0109/s')).toBeInTheDocument();
});
});

describe('Visualization', () => {
it('Formats duration data', () => {
render(
<BigNumberWidget
data={[
{
'p95(span.duration)': 17.28,
},
]}
meta={{
fields: {
'p95(span.duration)': 'duration',
},
units: {
'p95(span.duration)': 'milliseconds',
},
}}
/>
);

expect(screen.getByText('17.28ms')).toBeInTheDocument();
});

it('Shows the full unformatted value on hover', async () => {
render(
<BigNumberWidget
data={[
{
'count()': 178451214,
},
]}
meta={{
fields: {
'count()': 'integer',
},
units: {
'count()': null,
},
}}
/>
);

await userEvent.hover(screen.getByText('178m'));

expect(screen.getByText('178451214')).toBeInTheDocument();
});
});

describe('State', () => {
it('Shows a loading placeholder', () => {
render(<BigNumberWidget isLoading />);

expect(screen.getByText('—')).toBeInTheDocument();
});

it('Shows an error message', () => {
render(<BigNumberWidget error={new Error('Uh oh')} />);

expect(screen.getByText('Error: Uh oh')).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {Fragment} from 'react';
import styled from '@emotion/styled';

import JSXNode from 'sentry/components/stories/jsxNode';
import SideBySide from 'sentry/components/stories/sideBySide';
import SizingWindow from 'sentry/components/stories/sizingWindow';
import storyBook from 'sentry/stories/storyBook';
import {BigNumberWidget} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidget';

export default storyBook(BigNumberWidget, story => {
story('Getting Started', () => {
return (
<Fragment>
<p>
<JSXNode name="BigNumberWidget" /> is a Dashboard Widget Component. It displays
a single large value. Used in places like Dashboards Big Number widgets, Project
Details pages, and Organization Stats pages.
</p>
</Fragment>
);
});

story('Visualization', () => {
return (
<Fragment>
<p>
The visualization of <JSXNode name="BigNumberWidget" /> a large number, just
like it says on the tin. Depending on the data passed to it, it intelligently
rounds and humanizes the results. If the number is humanized, hovering over the
visualization shows a tooltip with the full value.
</p>

<SideBySide>
<SmallSizingWindow>
<BigNumberWidget
title="EPS"
description="Number of events per second"
data={[
{
'eps()': 0.01087819860850493,
},
]}
meta={{
fields: {
'eps()': 'rate',
},
units: {
'eps()': '1/second',
},
}}
/>
</SmallSizingWindow>
<SmallSizingWindow>
<BigNumberWidget
title="Count"
data={[
{
'count()': 178451214,
},
]}
meta={{
fields: {
'count()': 'integer',
},
units: {
'count()': null,
},
}}
/>
</SmallSizingWindow>
<SmallSizingWindow>
<BigNumberWidget
title="Query Duration"
description="p95(span.duration)"
data={[
{
'p95(span.duration)': 17.28,
},
]}
meta={{
fields: {
'p95(span.duration)': 'duration',
},
units: {
'p95(spa.duration)': 'milliseconds',
},
}}
/>
</SmallSizingWindow>
</SideBySide>
</Fragment>
);
});

story('State', () => {
return (
<Fragment>
<p>
<JSXNode name="BigNumberWidget" /> supports the usual loading and error states.
The loading state shows a simple placeholder.
</p>

<SideBySide>
<SmallSizingWindow>
<BigNumberWidget title="Count" isLoading />
</SmallSizingWindow>
<SmallSizingWindow>
<BigNumberWidget
title="Bad Count"
error={new Error('Something went wrong!')}
/>
</SmallSizingWindow>
</SideBySide>
</Fragment>
);
});
});

const SmallSizingWindow = styled(SizingWindow)`
width: auto;
height: 200px;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import styled from '@emotion/styled';

import {space} from 'sentry/styles/space';
import {
BigNumberWidgetVisualization,
type Props as BigNumberWidgetVisualizationProps,
} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization';
import {
type Props as WidgetFrameProps,
WidgetFrame,
} from 'sentry/views/dashboards/widgets/common/widgetFrame';

interface Props
extends Omit<WidgetFrameProps, 'children'>,
BigNumberWidgetVisualizationProps {}

export function BigNumberWidget(props: Props) {
return (
<WidgetFrame title={props.title} description={props.description}>
<BigNumberResizeWrapper>
<BigNumberWidgetVisualization
data={props.data}
meta={props.meta}
isLoading={props.isLoading}
error={props.error}
/>
</BigNumberResizeWrapper>
</WidgetFrame>
);
}

const BigNumberResizeWrapper = styled('div')`
flex-grow: 1;
overflow: hidden;
position: relative;
margin: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import styled from '@emotion/styled';

import {Tooltip} from 'sentry/components/tooltip';
import {defined} from 'sentry/utils';
import type {MetaType} from 'sentry/utils/discover/eventView';
import {getFieldFormatter} from 'sentry/utils/discover/fieldRenderers';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
import {AutoSizedText} from 'sentry/views/dashboards/widgetCard/autoSizedText';
import {NO_DATA_PLACEHOLDER} from 'sentry/views/dashboards/widgets/bigNumberWidget/settings';
import {ErrorPanel} from 'sentry/views/dashboards/widgets/common/errorPanel';
import type {
Meta,
StateProps,
TableData,
} from 'sentry/views/dashboards/widgets/common/types';

export interface Props extends StateProps {
data?: TableData;
meta?: Meta;
}

export function BigNumberWidgetVisualization(props: Props) {
const {data, meta, isLoading, error} = props;

const location = useLocation();
const organization = useOrganization();

if (error) {
return <ErrorPanel error={error} />;
}

// Big Number widgets only show one number, so we only ever look at the first item in the Discover response
const datum = data?.[0];
// TODO: Instrument getting more than one data key back as an error

if (isLoading || !defined(datum) || Object.keys(datum).length === 0) {
return (
<AutoResizeParent>
<AutoSizedText>
<Deemphasize>{NO_DATA_PLACEHOLDER}</Deemphasize>
</AutoSizedText>
</AutoResizeParent>
);
}

const fields = Object.keys(datum);
const field = fields[0];

// TODO: meta as MetaType is a white lie. `MetaType` doesn't know that types can be null, but they can!
const fieldFormatter = meta
? getFieldFormatter(field, meta as MetaType, false)
: value => value.toString();

const unit = meta?.units?.[field];
const rendered = fieldFormatter(datum, {
location,
organization,
unit: unit ?? undefined, // TODO: Field formatters think units can't be null but they can
});

return (
<AutoResizeParent>
<AutoSizedText>
<NumberContainerOverride>
<Tooltip title={datum[field]} isHoverable delay={0}>
{rendered}
</Tooltip>
</NumberContainerOverride>
</AutoSizedText>
</AutoResizeParent>
);
}

const AutoResizeParent = styled('div')`
position: absolute;
color: ${p => p.theme.headingColor};
inset: 0;

* {
line-height: 1;
text-align: left !important;
}
`;

const NumberContainerOverride = styled('div')`
display: inline-block;

* {
text-overflow: clip !important;
display: inline;
white-space: nowrap;
}
`;

const Deemphasize = styled('span')`
color: ${p => p.theme.gray300};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const NO_DATA_PLACEHOLDER = '\u2014';
34 changes: 34 additions & 0 deletions static/app/views/dashboards/widgets/common/errorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import styled from '@emotion/styled';

import {IconWarning} from 'sentry/icons';
import {space} from 'sentry/styles/space';
import type {StateProps} from 'sentry/views/dashboards/widgets/common/types';

interface Props {
error: StateProps['error'];
}

export function ErrorPanel({error}: Props) {
return (
<Panel>
<IconWarning color="gray500" size="lg" />
<span>{error?.toString()}</span>
</Panel>
);
}

const Panel = styled('div')<{height?: string}>`
position: absolute;
inset: 0;

display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: ${space(0.5)};

overflow: hidden;

color: ${p => p.theme.gray300};
font-size: ${p => p.theme.fontSizeExtraLarge};
`;
Loading
Loading