From f36ac0265243b03a3af6025a84ecd6dd086b9c6c Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:24:27 -0500 Subject: [PATCH] ref(dashboards): Extract `WidgetLayout` from `WidgetFrame` (#83760) `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! --- .../dashboards/widgets/common/widgetFrame.tsx | 304 ++++++------------ .../timeSeriesWidget/timeSeriesWidget.tsx | 21 +- .../{common => widgetLayout}/errorPanel.tsx | 2 +- .../widgets/widgetLayout/loadingPanel.tsx | 25 ++ .../widgets/widgetLayout/widgetBadge.tsx | 7 + .../widgets/widgetLayout/widgetButton.tsx | 11 + .../widgetLayout/widgetDescription.tsx | 55 ++++ .../widgetLayout/widgetLayout.stories.tsx | 108 +++++++ .../widgets/widgetLayout/widgetLayout.tsx | 98 ++++++ .../widgets/widgetLayout/widgetTitle.tsx | 21 ++ 10 files changed, 423 insertions(+), 229 deletions(-) rename static/app/views/dashboards/widgets/{common => widgetLayout}/errorPanel.tsx (95%) create mode 100644 static/app/views/dashboards/widgets/widgetLayout/loadingPanel.tsx create mode 100644 static/app/views/dashboards/widgets/widgetLayout/widgetBadge.tsx create mode 100644 static/app/views/dashboards/widgets/widgetLayout/widgetButton.tsx create mode 100644 static/app/views/dashboards/widgets/widgetLayout/widgetDescription.tsx create mode 100644 static/app/views/dashboards/widgets/widgetLayout/widgetLayout.stories.tsx create mode 100644 static/app/views/dashboards/widgets/widgetLayout/widgetLayout.tsx create mode 100644 static/app/views/dashboards/widgets/widgetLayout/widgetTitle.tsx diff --git a/static/app/views/dashboards/widgets/common/widgetFrame.tsx b/static/app/views/dashboards/widgets/common/widgetFrame.tsx index 7601f6f0af7fbb..5920691326ce4f 100644 --- a/static/app/views/dashboards/widgets/common/widgetFrame.tsx +++ b/static/app/views/dashboards/widgets/common/widgetFrame.tsx @@ -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; title?: string; warnings?: string[]; @@ -58,114 +58,89 @@ export function WidgetFrame(props: WidgetFrameProps) { const shouldShowActions = actions && actions.length > 0; return ( - -
- {props.warnings && props.warnings.length > 0 && ( - } isHoverable> - - - - - )} - - - {props.title} - - - {props.badgeProps && - (Array.isArray(props.badgeProps) ? props.badgeProps : [props.badgeProps]).map( - (currentBadgeProps, i) => + + {props.warnings && props.warnings.length > 0 && ( + } isHoverable> + + + + )} - {(props.description || shouldShowFullScreenViewButton || shouldShowActions) && ( - - {props.description && ( - // Ideally we'd use `QuestionTooltip` but we need to firstly paint the icon dark, give it 100% opacity, and remove hover behaviour. - - {props.title && ( - {props.title} - )} - {props.description && ( - - {props.description} - - )} - - } - containerDisplayMode="grid" - isHoverable - > - } - /> - - )} - - {shouldShowActions && ( - - {actions.length === 1 ? ( - actions[0]!.to ? ( - - {actions[0]!.label} - - ) : ( - - ) - ) : null} + - {actions.length > 1 ? ( - , - }} - position="bottom-end" - /> - ) : null} - + {props.badgeProps && + (Array.isArray(props.badgeProps) ? props.badgeProps : [props.badgeProps]).map( + (currentBadgeProps, i) => )} + + } + Actions={ + + {props.description && ( + // Ideally we'd use `QuestionTooltip` but we need to firstly paint the icon dark, give it 100% opacity, and remove hover behaviour. + + )} - {shouldShowFullScreenViewButton && ( -
+ {shouldShowActions && ( + + {actions.length === 1 ? ( + actions[0]!.to ? ( + + {actions[0]!.label} + + ) : ( + + {actions[0]!.label} + + ) + ) : null} + + {actions.length > 1 ? ( + , + }} + position="bottom-end" + /> + ) : null} + + )} - - {props.error ? ( + {shouldShowFullScreenViewButton && ( + } + onClick={() => { + props.onFullScreenViewClick?.(); + }} + /> + )} + + } + Visualization={ + props.error ? ( ) : ( {props.children} - )} - - + ) + } + /> ); } -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; @@ -206,82 +171,3 @@ function TitleActionsWrapper({disabled, disabledMessage, children}: TitleActions ); } - -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}; -`; diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx index 2e9b486ad29bdd..445e3ff2d104dd 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx @@ -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, @@ -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, @@ -28,10 +27,7 @@ export function TimeSeriesWidget(props: TimeSeriesWidgetProps) { if (props.isLoading) { return ( - - - - + ); } @@ -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}; -`; diff --git a/static/app/views/dashboards/widgets/common/errorPanel.tsx b/static/app/views/dashboards/widgets/widgetLayout/errorPanel.tsx similarity index 95% rename from static/app/views/dashboards/widgets/common/errorPanel.tsx rename to static/app/views/dashboards/widgets/widgetLayout/errorPanel.tsx index a5cd491752679b..0d0dd9d46cfce5 100644 --- a/static/app/views/dashboards/widgets/common/errorPanel.tsx +++ b/static/app/views/dashboards/widgets/widgetLayout/errorPanel.tsx @@ -5,7 +5,7 @@ import {space} from 'sentry/styles/space'; import {DEEMPHASIS_COLOR_NAME} from 'sentry/views/dashboards/widgets/bigNumberWidget/settings'; import type {StateProps} from 'sentry/views/dashboards/widgets/common/types'; -import {X_GUTTER, Y_GUTTER} from './settings'; +import {X_GUTTER, Y_GUTTER} from '../common/settings'; interface ErrorPanelProps { error: StateProps['error']; diff --git a/static/app/views/dashboards/widgets/widgetLayout/loadingPanel.tsx b/static/app/views/dashboards/widgets/widgetLayout/loadingPanel.tsx new file mode 100644 index 00000000000000..e1ad92b17ba554 --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/loadingPanel.tsx @@ -0,0 +1,25 @@ +import styled from '@emotion/styled'; + +import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; + +export function LoadingPanel() { + return ( + + + + + ); +} +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}; +`; diff --git a/static/app/views/dashboards/widgets/widgetLayout/widgetBadge.tsx b/static/app/views/dashboards/widgets/widgetLayout/widgetBadge.tsx new file mode 100644 index 00000000000000..f1ba67b8d511df --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/widgetBadge.tsx @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +import Badge from 'sentry/components/badge/badge'; + +export const WidgetBadge = styled(Badge)` + flex-shrink: 0; +`; diff --git a/static/app/views/dashboards/widgets/widgetLayout/widgetButton.tsx b/static/app/views/dashboards/widgets/widgetLayout/widgetButton.tsx new file mode 100644 index 00000000000000..3678286ad0313e --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/widgetButton.tsx @@ -0,0 +1,11 @@ +import type {ComponentProps} from 'react'; + +import {Button} from 'sentry/components/button'; + +export function WidgetButton(props: Omit, 'size'>) { + return ( + + ); +} diff --git a/static/app/views/dashboards/widgets/widgetLayout/widgetDescription.tsx b/static/app/views/dashboards/widgets/widgetLayout/widgetDescription.tsx new file mode 100644 index 00000000000000..bba057e75b66fb --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/widgetDescription.tsx @@ -0,0 +1,55 @@ +import styled from '@emotion/styled'; + +import {Button} from 'sentry/components/button'; +import {Tooltip} from 'sentry/components/tooltip'; +import {IconInfo} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; + +export interface WidgetDescriptionProps { + description?: React.ReactElement | string; + title?: string; +} + +export function WidgetDescription(props: WidgetDescriptionProps) { + return ( + + {props.title && {props.title}} + {props.description && ( + {props.description} + )} + + } + containerDisplayMode="grid" + isHoverable + > + } + /> + + ); +} + +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; +`; diff --git a/static/app/views/dashboards/widgets/widgetLayout/widgetLayout.stories.tsx b/static/app/views/dashboards/widgets/widgetLayout/widgetLayout.stories.tsx new file mode 100644 index 00000000000000..a461f7e7b85c11 --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/widgetLayout.stories.tsx @@ -0,0 +1,108 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import {CodeSnippet} from 'sentry/components/codeSnippet'; +import JSXNode from 'sentry/components/stories/jsxNode'; +import SizingWindow from 'sentry/components/stories/sizingWindow'; +import storyBook from 'sentry/stories/storyBook'; + +import {LineChartWidgetVisualization} from '../lineChartWidget/lineChartWidgetVisualization'; +import sampleDurationTimeSeries from '../lineChartWidget/sampleDurationTimeSeries.json'; + +import {WidgetButton} from './widgetButton'; +import {WidgetDescription} from './widgetDescription'; +import {WidgetLayout} from './widgetLayout'; +import {WidgetTitle} from './widgetTitle'; + +export default storyBook(WidgetLayout, story => { + story('Getting Started', () => { + return ( + +

+ In most cases, we recommend using standard widgets like{' '} + . If this isn't possible (because of custom + layout needs), we offer a set of helper components. Components like{' '} + can be used to create a standard-looking widget + from near-scratch. +

+
+ ); + }); + + story('WidgetLayout', () => { + return ( + +

+ is a layout-only component. It contains no + logic, all it does it place the passed components in correct locations in a + bordered widget frame. The contents of the Title prop are shown in + the top left, and are always visible. The title is truncated to fit. The + contents of the Actions prop are shown in the top right, and only + shown on hover. Actions are not truncated. The contents of{' '} + Visualization are always visible, shown below the title and + actions. The layout expands both horizontally and vertically to fit the parent. +

+ +

+ In order to make a nice-looking custom widget layout we recommend using the + pre-built components that we provide alongside the layout. +

+ + + {`import {LineChartWidgetVisualization} from '../lineChartWidget/lineChartWidgetVisualization'; +import sampleDurationTimeSeries from '../lineChartWidget/sampleDurationTimeSeries.json'; + +import {WidgetButton} from './widgetButton'; +import {WidgetDescription} from './widgetDescription'; +import {WidgetLayout} from './widgetLayout'; +import {WidgetTitle} from './widgetTitle'; + +} + Actions={ + + Say More + Say Less + + + } + Visualization={ + + } + Caption={

This data is incomplete!

} +/> + + `} +
+ + + } + Actions={ + + Say More + Say Less + + + } + Visualization={ + + } + Caption={

This data is incomplete!

} + /> +
+
+ ); + }); +}); + +const SmallSizingWindow = styled(SizingWindow)` + width: 400px; + height: 300px; +`; diff --git a/static/app/views/dashboards/widgets/widgetLayout/widgetLayout.tsx b/static/app/views/dashboards/widgets/widgetLayout/widgetLayout.tsx new file mode 100644 index 00000000000000..30523048dad832 --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/widgetLayout.tsx @@ -0,0 +1,98 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import {space} from 'sentry/styles/space'; + +import {MIN_HEIGHT, MIN_WIDTH, X_GUTTER, Y_GUTTER} from '../common/settings'; + +export interface WidgetLayoutProps { + Actions?: React.ReactNode; + Caption?: React.ReactNode; + Title?: React.ReactNode; + Visualization?: React.ReactNode; + ariaLabel?: string; + height?: number; +} + +export function WidgetLayout(props: WidgetLayoutProps) { + return ( + +
+ {props.Title && {props.Title}} + {props.Actions && {props.Actions}} +
+ + {props.Visualization && ( + {props.Visualization} + )} + + {props.Caption && {props.Caption}} + + ); +} + +const HEADER_HEIGHT = '26px'; + +const TitleHoverItems = styled('div')` + display: flex; + align-items: center; + gap: ${space(0.5)}; + margin-left: auto; + + opacity: 1; + transition: opacity 0.1s; +`; + +const Frame = styled('div')<{height?: number}>` + position: relative; + display: flex; + flex-direction: column; + + height: ${p => (p.height ? `${p.height}px` : '100%')}; + min-height: ${MIN_HEIGHT}px; + width: 100%; + min-width: ${MIN_WIDTH}px; + + border-radius: ${p => p.theme.panelBorderRadius}; + border: 1px solid ${p => 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 = 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 VisualizationWrapper = styled('div')` + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 0; + position: relative; + padding: 0 ${X_GUTTER} ${Y_GUTTER} ${X_GUTTER}; +`; + +const CaptionWrapper = styled('div')` + padding: ${space(0.5)} ${X_GUTTER} ${Y_GUTTER} ${X_GUTTER}; + margin: 0; +`; diff --git a/static/app/views/dashboards/widgets/widgetLayout/widgetTitle.tsx b/static/app/views/dashboards/widgets/widgetLayout/widgetTitle.tsx new file mode 100644 index 00000000000000..b1878055f81c23 --- /dev/null +++ b/static/app/views/dashboards/widgets/widgetLayout/widgetTitle.tsx @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; + +import {HeaderTitle} from 'sentry/components/charts/styles'; +import {Tooltip} from 'sentry/components/tooltip'; + +export interface WidgetTitleProps { + title?: string; +} + +export function WidgetTitle(props: WidgetTitleProps) { + return ( + + {props.title} + + ); +} + +const TitleText = styled(HeaderTitle)` + ${p => p.theme.overflowEllipsis}; + font-weight: ${p => p.theme.fontWeightBold}; +`;