widgetFrame.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import styled from '@emotion/styled';
  2. import Badge, {type BadgeProps} from 'sentry/components/badge/badge';
  3. import {Button, LinkButton} from 'sentry/components/button';
  4. import {HeaderTitle} from 'sentry/components/charts/styles';
  5. import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
  6. import {Tooltip} from 'sentry/components/tooltip';
  7. import {IconEllipsis, IconExpand, IconInfo, IconWarning} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {ErrorPanel} from './errorPanel';
  11. import {MIN_HEIGHT, MIN_WIDTH, X_GUTTER, Y_GUTTER} from './settings';
  12. import {TooltipIconTrigger} from './tooltipIconTrigger';
  13. import type {StateProps} from './types';
  14. import {WarningsList} from './warningsList';
  15. export interface WidgetFrameProps extends StateProps {
  16. actions?: MenuItemProps[];
  17. actionsDisabled?: boolean;
  18. actionsMessage?: string;
  19. badgeProps?: BadgeProps | BadgeProps[];
  20. children?: React.ReactNode;
  21. description?: React.ReactElement | string;
  22. onFullScreenViewClick?: () => void;
  23. title?: string;
  24. warnings?: string[];
  25. }
  26. export function WidgetFrame(props: WidgetFrameProps) {
  27. const {error} = props;
  28. // The error state has its own set of available actions
  29. const actions =
  30. (error
  31. ? props.onRetry
  32. ? [
  33. {
  34. key: 'retry',
  35. label: t('Retry'),
  36. onAction: props.onRetry,
  37. },
  38. ]
  39. : []
  40. : props.actions) ?? [];
  41. return (
  42. <Frame aria-label="Widget panel">
  43. <Header>
  44. {props.warnings && props.warnings.length > 0 && (
  45. <Tooltip title={<WarningsList warnings={props.warnings} />} isHoverable>
  46. <TooltipIconTrigger aria-label={t('Widget warnings')}>
  47. <IconWarning color="warningText" />
  48. </TooltipIconTrigger>
  49. </Tooltip>
  50. )}
  51. <Tooltip title={props.title} containerDisplayMode="grid" showOnlyOnOverflow>
  52. <TitleText>{props.title}</TitleText>
  53. </Tooltip>
  54. {props.badgeProps &&
  55. (Array.isArray(props.badgeProps) ? props.badgeProps : [props.badgeProps]).map(
  56. (currentBadgeProps, i) => <RigidBadge key={i} {...currentBadgeProps} />
  57. )}
  58. {(props.description ||
  59. props.onFullScreenViewClick ||
  60. (actions && actions.length > 0)) && (
  61. <TitleHoverItems>
  62. {props.description && (
  63. // Ideally we'd use `QuestionTooltip` but we need to firstly paint the icon dark, give it 100% opacity, and remove hover behaviour.
  64. <Tooltip
  65. title={
  66. <span>
  67. {props.title && (
  68. <WidgetTooltipTitle>{props.title}</WidgetTooltipTitle>
  69. )}
  70. {props.description && (
  71. <WidgetTooltipDescription>
  72. {props.description}
  73. </WidgetTooltipDescription>
  74. )}
  75. </span>
  76. }
  77. containerDisplayMode="grid"
  78. isHoverable
  79. >
  80. <WidgetTooltipButton
  81. aria-label={t('Widget description')}
  82. borderless
  83. size="xs"
  84. icon={<IconInfo size="sm" />}
  85. />
  86. </Tooltip>
  87. )}
  88. <TitleActionsWrapper
  89. disabled={Boolean(props.actionsDisabled)}
  90. disabledMessage={props.actionsMessage ?? ''}
  91. >
  92. {actions.length === 1 ? (
  93. actions[0].to ? (
  94. <LinkButton
  95. size="xs"
  96. disabled={props.actionsDisabled}
  97. onClick={actions[0].onAction}
  98. to={actions[0].to}
  99. >
  100. {actions[0].label}
  101. </LinkButton>
  102. ) : (
  103. <Button
  104. size="xs"
  105. disabled={props.actionsDisabled}
  106. onClick={actions[0].onAction}
  107. >
  108. {actions[0].label}
  109. </Button>
  110. )
  111. ) : null}
  112. {actions.length > 1 ? (
  113. <DropdownMenu
  114. items={actions}
  115. isDisabled={props.actionsDisabled}
  116. triggerProps={{
  117. 'aria-label': t('Widget actions'),
  118. size: 'xs',
  119. borderless: true,
  120. showChevron: false,
  121. icon: <IconEllipsis direction="down" size="sm" />,
  122. }}
  123. position="bottom-end"
  124. />
  125. ) : null}
  126. </TitleActionsWrapper>
  127. {props.onFullScreenViewClick && (
  128. <Button
  129. aria-label={t('Open Full-Screen View')}
  130. borderless
  131. size="xs"
  132. icon={<IconExpand />}
  133. onClick={() => {
  134. props.onFullScreenViewClick?.();
  135. }}
  136. />
  137. )}
  138. </TitleHoverItems>
  139. )}
  140. </Header>
  141. <VisualizationWrapper>
  142. {props.error ? <ErrorPanel error={error} /> : props.children}
  143. </VisualizationWrapper>
  144. </Frame>
  145. );
  146. }
  147. const TitleHoverItems = styled('div')`
  148. display: flex;
  149. align-items: center;
  150. gap: ${space(0.5)};
  151. margin-left: auto;
  152. opacity: 1;
  153. transition: opacity 0.1s;
  154. `;
  155. interface TitleActionsProps {
  156. children: React.ReactNode;
  157. disabled: boolean;
  158. disabledMessage: string;
  159. }
  160. function TitleActionsWrapper({disabled, disabledMessage, children}: TitleActionsProps) {
  161. if (!disabled || !disabledMessage) {
  162. return children;
  163. }
  164. return (
  165. <Tooltip title={disabledMessage} isHoverable>
  166. {children}
  167. </Tooltip>
  168. );
  169. }
  170. const Frame = styled('div')`
  171. position: relative;
  172. display: flex;
  173. flex-direction: column;
  174. height: 100%;
  175. min-height: ${MIN_HEIGHT}px;
  176. width: 100%;
  177. min-width: ${MIN_WIDTH}px;
  178. border-radius: ${p => p.theme.panelBorderRadius};
  179. border: ${p => p.theme.border};
  180. border: 1px ${p => 'solid ' + p.theme.border};
  181. background: ${p => p.theme.background};
  182. :hover {
  183. background-color: ${p => p.theme.surface200};
  184. transition:
  185. background-color 100ms linear,
  186. box-shadow 100ms linear;
  187. box-shadow: ${p => p.theme.dropShadowLight};
  188. }
  189. &:not(:hover):not(:focus-within) {
  190. ${TitleHoverItems} {
  191. opacity: 0;
  192. ${p => p.theme.visuallyHidden}
  193. }
  194. }
  195. `;
  196. const HEADER_HEIGHT = '26px';
  197. const Header = styled('div')`
  198. display: flex;
  199. align-items: center;
  200. height: calc(${HEADER_HEIGHT} + ${Y_GUTTER});
  201. flex-shrink: 0;
  202. gap: ${space(0.75)};
  203. padding: ${X_GUTTER} ${Y_GUTTER} 0 ${X_GUTTER};
  204. `;
  205. const TitleText = styled(HeaderTitle)`
  206. ${p => p.theme.overflowEllipsis};
  207. font-weight: ${p => p.theme.fontWeightBold};
  208. `;
  209. const RigidBadge = styled(Badge)`
  210. flex-shrink: 0;
  211. `;
  212. const WidgetTooltipTitle = styled('div')`
  213. font-weight: bold;
  214. font-size: ${p => p.theme.fontSizeMedium};
  215. text-align: left;
  216. `;
  217. const WidgetTooltipDescription = styled('div')`
  218. margin-top: ${space(0.5)};
  219. font-size: ${p => p.theme.fontSizeSmall};
  220. text-align: left;
  221. `;
  222. // We're using a button here to preserve tab accessibility
  223. const WidgetTooltipButton = styled(Button)`
  224. pointer-events: none;
  225. padding-top: 0;
  226. padding-bottom: 0;
  227. `;
  228. const VisualizationWrapper = styled('div')`
  229. display: flex;
  230. flex-direction: column;
  231. flex-grow: 1;
  232. min-height: 0;
  233. position: relative;
  234. `;