Skip to content

Commit

Permalink
ref(dashboards): Extract WidgetLayout from WidgetFrame (#83760)
Browse files Browse the repository at this point in the history
`WidgetLayout` is the innards of `WidgetFrame,` split up into separate
components, exported, and documented.

This makes it possible to create your own custom widget and have it look
pretty standard. Good for exception cases like Explore that would blow
up the "standard" widgets. This is almost a pure refactor, just moving
some components around. The only difference is adding first-class
support for widget captions. No visual changes here!
  • Loading branch information
gggritso authored and andrewshie-sentry committed Jan 22, 2025
1 parent 21775e5 commit f36ac02
Show file tree
Hide file tree
Showing 10 changed files with 423 additions and 229 deletions.
304 changes: 95 additions & 209 deletions static/app/views/dashboards/widgets/common/widgetFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
import styled from '@emotion/styled';
import {Fragment} from 'react';

import Badge, {type BadgeProps} from 'sentry/components/badge/badge';
import {Button, LinkButton} from 'sentry/components/button';
import {HeaderTitle} from 'sentry/components/charts/styles';
import type {BadgeProps} from 'sentry/components/badge/badge';
import {LinkButton} from 'sentry/components/button';
import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
import ErrorBoundary from 'sentry/components/errorBoundary';
import {Tooltip} from 'sentry/components/tooltip';
import {IconEllipsis, IconExpand, IconInfo, IconWarning} from 'sentry/icons';
import {IconEllipsis, IconExpand, IconWarning} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';

import {ErrorPanel} from './errorPanel';
import {ErrorPanel} from '../widgetLayout/errorPanel';
import {WidgetBadge} from '../widgetLayout/widgetBadge';
import {WidgetButton} from '../widgetLayout/widgetButton';
import {
MIN_HEIGHT,
MIN_WIDTH,
WIDGET_RENDER_ERROR_MESSAGE,
X_GUTTER,
Y_GUTTER,
} from './settings';
WidgetDescription,
type WidgetDescriptionProps,
} from '../widgetLayout/widgetDescription';
import {WidgetLayout} from '../widgetLayout/widgetLayout';
import {WidgetTitle} from '../widgetLayout/widgetTitle';

import {WIDGET_RENDER_ERROR_MESSAGE} from './settings';
import {TooltipIconTrigger} from './tooltipIconTrigger';
import type {StateProps} from './types';
import {WarningsList} from './warningsList';

export interface WidgetFrameProps extends StateProps {
export interface WidgetFrameProps extends StateProps, WidgetDescriptionProps {
actions?: MenuItemProps[];
actionsDisabled?: boolean;
actionsMessage?: string;
badgeProps?: BadgeProps | BadgeProps[];
borderless?: boolean;
children?: React.ReactNode;
description?: React.ReactElement | string;
onFullScreenViewClick?: () => void | Promise<void>;
title?: string;
warnings?: string[];
Expand Down Expand Up @@ -58,137 +58,102 @@ export function WidgetFrame(props: WidgetFrameProps) {
const shouldShowActions = actions && actions.length > 0;

return (
<Frame aria-label="Widget panel" borderless={props.borderless}>
<Header>
{props.warnings && props.warnings.length > 0 && (
<Tooltip title={<WarningsList warnings={props.warnings} />} isHoverable>
<TooltipIconTrigger aria-label={t('Widget warnings')}>
<IconWarning color="warningText" />
</TooltipIconTrigger>
</Tooltip>
)}

<Tooltip title={props.title} containerDisplayMode="grid" showOnlyOnOverflow>
<TitleText>{props.title}</TitleText>
</Tooltip>

{props.badgeProps &&
(Array.isArray(props.badgeProps) ? props.badgeProps : [props.badgeProps]).map(
(currentBadgeProps, i) => <RigidBadge key={i} {...currentBadgeProps} />
<WidgetLayout
ariaLabel="Widget panel"
Title={
<Fragment>
{props.warnings && props.warnings.length > 0 && (
<Tooltip title={<WarningsList warnings={props.warnings} />} isHoverable>
<TooltipIconTrigger aria-label={t('Widget warnings')}>
<IconWarning color="warningText" />
</TooltipIconTrigger>
</Tooltip>
)}

{(props.description || shouldShowFullScreenViewButton || shouldShowActions) && (
<TitleHoverItems>
{props.description && (
// Ideally we'd use `QuestionTooltip` but we need to firstly paint the icon dark, give it 100% opacity, and remove hover behaviour.
<Tooltip
title={
<span>
{props.title && (
<WidgetTooltipTitle>{props.title}</WidgetTooltipTitle>
)}
{props.description && (
<WidgetTooltipDescription>
{props.description}
</WidgetTooltipDescription>
)}
</span>
}
containerDisplayMode="grid"
isHoverable
>
<WidgetTooltipButton
aria-label={t('Widget description')}
borderless
size="xs"
icon={<IconInfo size="sm" />}
/>
</Tooltip>
)}

{shouldShowActions && (
<TitleActionsWrapper
disabled={Boolean(props.actionsDisabled)}
disabledMessage={props.actionsMessage ?? ''}
>
{actions.length === 1 ? (
actions[0]!.to ? (
<LinkButton
size="xs"
disabled={props.actionsDisabled}
onClick={actions[0]!.onAction}
to={actions[0]!.to}
>
{actions[0]!.label}
</LinkButton>
) : (
<Button
size="xs"
disabled={props.actionsDisabled}
onClick={actions[0]!.onAction}
>
{actions[0]!.label}
</Button>
)
) : null}
<WidgetTitle title={props.title} />

{actions.length > 1 ? (
<DropdownMenu
items={actions}
isDisabled={props.actionsDisabled}
triggerProps={{
'aria-label': t('Widget actions'),
size: 'xs',
borderless: true,
showChevron: false,
icon: <IconEllipsis direction="down" size="sm" />,
}}
position="bottom-end"
/>
) : null}
</TitleActionsWrapper>
{props.badgeProps &&
(Array.isArray(props.badgeProps) ? props.badgeProps : [props.badgeProps]).map(
(currentBadgeProps, i) => <WidgetBadge key={i} {...currentBadgeProps} />
)}
</Fragment>
}
Actions={
<Fragment>
{props.description && (
// Ideally we'd use `QuestionTooltip` but we need to firstly paint the icon dark, give it 100% opacity, and remove hover behaviour.
<WidgetDescription title={props.title} description={props.description} />
)}

{shouldShowFullScreenViewButton && (
<Button
aria-label={t('Open Full-Screen View')}
borderless
size="xs"
icon={<IconExpand />}
onClick={() => {
props.onFullScreenViewClick?.();
}}
/>
)}
</TitleHoverItems>
)}
</Header>
{shouldShowActions && (
<TitleActionsWrapper
disabled={Boolean(props.actionsDisabled)}
disabledMessage={props.actionsMessage ?? ''}
>
{actions.length === 1 ? (
actions[0]!.to ? (
<LinkButton
size="xs"
disabled={props.actionsDisabled}
onClick={actions[0]!.onAction}
to={actions[0]!.to}
>
{actions[0]!.label}
</LinkButton>
) : (
<WidgetButton
disabled={props.actionsDisabled}
onClick={actions[0]!.onAction}
>
{actions[0]!.label}
</WidgetButton>
)
) : null}

{actions.length > 1 ? (
<DropdownMenu
items={actions}
isDisabled={props.actionsDisabled}
triggerProps={{
'aria-label': t('Widget actions'),
size: 'xs',
borderless: true,
showChevron: false,
icon: <IconEllipsis direction="down" size="sm" />,
}}
position="bottom-end"
/>
) : null}
</TitleActionsWrapper>
)}

<VisualizationWrapper>
{props.error ? (
{shouldShowFullScreenViewButton && (
<WidgetButton
aria-label={t('Open Full-Screen View')}
borderless
icon={<IconExpand />}
onClick={() => {
props.onFullScreenViewClick?.();
}}
/>
)}
</Fragment>
}
Visualization={
props.error ? (
<ErrorPanel error={error} />
) : (
<ErrorBoundary
customComponent={<ErrorPanel error={WIDGET_RENDER_ERROR_MESSAGE} />}
>
{props.children}
</ErrorBoundary>
)}
</VisualizationWrapper>
</Frame>
)
}
/>
);
}

const TitleHoverItems = styled('div')`
display: flex;
align-items: center;
gap: ${space(0.5)};
margin-left: auto;
opacity: 1;
transition: opacity 0.1s;
`;

interface TitleActionsProps {
children: React.ReactNode;
disabled: boolean;
Expand All @@ -206,82 +171,3 @@ function TitleActionsWrapper({disabled, disabledMessage, children}: TitleActions
</Tooltip>
);
}

const Frame = styled('div')<{borderless?: boolean}>`
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: ${MIN_HEIGHT}px;
width: 100%;
min-width: ${MIN_WIDTH}px;
border-radius: ${p => p.theme.panelBorderRadius};
${p => !p.borderless && `border: 1px solid ${p.theme.border};`}
background: ${p => p.theme.background};
:hover {
background-color: ${p => p.theme.surface200};
transition:
background-color 100ms linear,
box-shadow 100ms linear;
box-shadow: ${p => p.theme.dropShadowLight};
}
&:not(:hover):not(:focus-within) {
${TitleHoverItems} {
opacity: 0;
${p => p.theme.visuallyHidden}
}
}
`;

const HEADER_HEIGHT = '26px';

const Header = styled('div')`
display: flex;
align-items: center;
height: calc(${HEADER_HEIGHT} + ${Y_GUTTER});
flex-shrink: 0;
gap: ${space(0.75)};
padding: ${X_GUTTER} ${Y_GUTTER} 0 ${X_GUTTER};
`;

const TitleText = styled(HeaderTitle)`
${p => p.theme.overflowEllipsis};
font-weight: ${p => p.theme.fontWeightBold};
`;

const RigidBadge = styled(Badge)`
flex-shrink: 0;
`;

const WidgetTooltipTitle = styled('div')`
font-weight: bold;
font-size: ${p => p.theme.fontSizeMedium};
text-align: left;
`;

const WidgetTooltipDescription = styled('div')`
margin-top: ${space(0.5)};
font-size: ${p => p.theme.fontSizeSmall};
text-align: left;
`;

// We're using a button here to preserve tab accessibility
const WidgetTooltipButton = styled(Button)`
pointer-events: none;
padding-top: 0;
padding-bottom: 0;
`;

const VisualizationWrapper = styled('div')`
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
position: relative;
padding: 0 ${X_GUTTER} ${Y_GUTTER} ${X_GUTTER};
`;
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import styled from '@emotion/styled';

import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {defined} from 'sentry/utils';
import {
WidgetFrame,
Expand All @@ -14,6 +12,7 @@ import {

import {MISSING_DATA_MESSAGE} from '../common/settings';
import type {StateProps} from '../common/types';
import {LoadingPanel} from '../widgetLayout/loadingPanel';

export interface TimeSeriesWidgetProps
extends StateProps,
Expand All @@ -28,10 +27,7 @@ export function TimeSeriesWidget(props: TimeSeriesWidgetProps) {
if (props.isLoading) {
return (
<WidgetFrame title={props.title} description={props.description}>
<LoadingPlaceholder>
<LoadingMask visible />
<LoadingIndicator mini />
</LoadingPlaceholder>
<LoadingPanel />
</WidgetFrame>
);
}
Expand Down Expand Up @@ -77,16 +73,3 @@ export function TimeSeriesWidget(props: TimeSeriesWidgetProps) {
const TimeSeriesWrapper = styled('div')`
flex-grow: 1;
`;

const LoadingPlaceholder = styled('div')`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
`;

const LoadingMask = styled(TransparentLoadingMask)`
background: ${p => p.theme.background};
`;
Loading

0 comments on commit f36ac02

Please sign in to comment.