widgetCard.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import * as React from 'react';
  2. import * as ReactRouter from 'react-router';
  3. import {browserHistory} from 'react-router';
  4. import {useSortable} from '@dnd-kit/sortable';
  5. import styled from '@emotion/styled';
  6. import {Location} from 'history';
  7. import isEqual from 'lodash/isEqual';
  8. import {Client} from 'app/api';
  9. import {HeaderTitle} from 'app/components/charts/styles';
  10. import ErrorBoundary from 'app/components/errorBoundary';
  11. import MenuItem from 'app/components/menuItem';
  12. import {isSelectionEqual} from 'app/components/organizations/globalSelectionHeader/utils';
  13. import {Panel} from 'app/components/panels';
  14. import Placeholder from 'app/components/placeholder';
  15. import {IconDelete, IconEdit, IconGrabbable} from 'app/icons';
  16. import {t} from 'app/locale';
  17. import overflowEllipsis from 'app/styles/overflowEllipsis';
  18. import space from 'app/styles/space';
  19. import {GlobalSelection, Organization} from 'app/types';
  20. import {trackAnalyticsEvent} from 'app/utils/analytics';
  21. import withApi from 'app/utils/withApi';
  22. import withGlobalSelection from 'app/utils/withGlobalSelection';
  23. import withOrganization from 'app/utils/withOrganization';
  24. import ContextMenu from './contextMenu';
  25. import {Widget} from './types';
  26. import {eventViewFromWidget} from './utils';
  27. import WidgetCardChart from './widgetCardChart';
  28. import WidgetQueries from './widgetQueries';
  29. type DraggableProps = Pick<ReturnType<typeof useSortable>, 'attributes' | 'listeners'>;
  30. type Props = ReactRouter.WithRouterProps & {
  31. api: Client;
  32. organization: Organization;
  33. location: Location;
  34. isEditing: boolean;
  35. widget: Widget;
  36. selection: GlobalSelection;
  37. onDelete: () => void;
  38. onEdit: () => void;
  39. isSorting: boolean;
  40. currentWidgetDragging: boolean;
  41. showContextMenu?: boolean;
  42. hideToolbar?: boolean;
  43. draggableProps?: DraggableProps;
  44. renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
  45. };
  46. class WidgetCard extends React.Component<Props> {
  47. shouldComponentUpdate(nextProps: Props): boolean {
  48. if (
  49. !isEqual(nextProps.widget, this.props.widget) ||
  50. !isSelectionEqual(nextProps.selection, this.props.selection) ||
  51. this.props.isEditing !== nextProps.isEditing ||
  52. this.props.isSorting !== nextProps.isSorting ||
  53. this.props.hideToolbar !== nextProps.hideToolbar
  54. ) {
  55. return true;
  56. }
  57. return false;
  58. }
  59. renderToolbar() {
  60. const {onEdit, onDelete, draggableProps, hideToolbar, isEditing} = this.props;
  61. if (!isEditing) {
  62. return null;
  63. }
  64. return (
  65. <ToolbarPanel>
  66. <IconContainer style={{visibility: hideToolbar ? 'hidden' : 'visible'}}>
  67. <IconClick>
  68. <StyledIconGrabbable
  69. color="textColor"
  70. {...draggableProps?.listeners}
  71. {...draggableProps?.attributes}
  72. />
  73. </IconClick>
  74. <IconClick
  75. data-test-id="widget-edit"
  76. onClick={() => {
  77. onEdit();
  78. }}
  79. >
  80. <IconEdit color="textColor" />
  81. </IconClick>
  82. <IconClick
  83. data-test-id="widget-delete"
  84. onClick={() => {
  85. onDelete();
  86. }}
  87. >
  88. <IconDelete color="textColor" />
  89. </IconClick>
  90. </IconContainer>
  91. </ToolbarPanel>
  92. );
  93. }
  94. renderContextMenu() {
  95. const {widget, selection, organization, showContextMenu} = this.props;
  96. if (!showContextMenu) {
  97. return null;
  98. }
  99. const menuOptions: React.ReactNode[] = [];
  100. if (
  101. widget.displayType === 'table' &&
  102. organization.features.includes('discover-basic')
  103. ) {
  104. // Open table widget in Discover
  105. if (widget.queries.length) {
  106. // We expect Table widgets to have only one query.
  107. const query = widget.queries[0];
  108. const eventView = eventViewFromWidget(widget.title, query, selection);
  109. menuOptions.push(
  110. <MenuItem
  111. key="open-discover"
  112. onClick={event => {
  113. event.preventDefault();
  114. trackAnalyticsEvent({
  115. eventKey: 'dashboards2.tablewidget.open_in_discover',
  116. eventName: 'Dashboards2: Table Widget - Open in Discover',
  117. organization_id: parseInt(this.props.organization.id, 10),
  118. });
  119. browserHistory.push(eventView.getResultsViewUrlTarget(organization.slug));
  120. }}
  121. >
  122. {t('Open in Discover')}
  123. </MenuItem>
  124. );
  125. }
  126. }
  127. if (!menuOptions.length) {
  128. return null;
  129. }
  130. return (
  131. <ContextWrapper>
  132. <ContextMenu>{menuOptions}</ContextMenu>
  133. </ContextWrapper>
  134. );
  135. }
  136. render() {
  137. const {
  138. widget,
  139. api,
  140. organization,
  141. selection,
  142. renderErrorMessage,
  143. location,
  144. router,
  145. } = this.props;
  146. return (
  147. <ErrorBoundary
  148. customComponent={<ErrorCard>{t('Error loading widget data')}</ErrorCard>}
  149. >
  150. <StyledPanel isDragging={false}>
  151. <WidgetHeader>
  152. <WidgetTitle>{widget.title}</WidgetTitle>
  153. {this.renderContextMenu()}
  154. </WidgetHeader>
  155. <WidgetQueries
  156. api={api}
  157. organization={organization}
  158. widget={widget}
  159. selection={selection}
  160. >
  161. {({tableResults, timeseriesResults, errorMessage, loading}) => {
  162. return (
  163. <React.Fragment>
  164. {typeof renderErrorMessage === 'function'
  165. ? renderErrorMessage(errorMessage)
  166. : null}
  167. <WidgetCardChart
  168. timeseriesResults={timeseriesResults}
  169. tableResults={tableResults}
  170. errorMessage={errorMessage}
  171. loading={loading}
  172. location={location}
  173. widget={widget}
  174. selection={selection}
  175. router={router}
  176. organization={organization}
  177. />
  178. {this.renderToolbar()}
  179. </React.Fragment>
  180. );
  181. }}
  182. </WidgetQueries>
  183. </StyledPanel>
  184. </ErrorBoundary>
  185. );
  186. }
  187. }
  188. export default withApi(
  189. withOrganization(withGlobalSelection(ReactRouter.withRouter(WidgetCard)))
  190. );
  191. const ErrorCard = styled(Placeholder)`
  192. display: flex;
  193. align-items: center;
  194. justify-content: center;
  195. background-color: ${p => p.theme.alert.error.backgroundLight};
  196. border: 1px solid ${p => p.theme.alert.error.border};
  197. color: ${p => p.theme.alert.error.textLight};
  198. border-radius: ${p => p.theme.borderRadius};
  199. margin-bottom: ${space(2)};
  200. `;
  201. const StyledPanel = styled(Panel, {
  202. shouldForwardProp: prop => prop !== 'isDragging',
  203. })<{
  204. isDragging: boolean;
  205. }>`
  206. margin: 0;
  207. visibility: ${p => (p.isDragging ? 'hidden' : 'visible')};
  208. /* If a panel overflows due to a long title stretch its grid sibling */
  209. height: 100%;
  210. min-height: 96px;
  211. `;
  212. const ToolbarPanel = styled('div')`
  213. position: absolute;
  214. top: 0;
  215. left: 0;
  216. z-index: 1;
  217. width: 100%;
  218. height: 100%;
  219. display: flex;
  220. justify-content: flex-end;
  221. align-items: flex-start;
  222. background-color: ${p => p.theme.overlayBackgroundAlpha};
  223. border-radius: ${p => p.theme.borderRadius};
  224. `;
  225. const IconContainer = styled('div')`
  226. display: flex;
  227. margin: 10px ${space(2)};
  228. touch-action: none;
  229. `;
  230. const IconClick = styled('div')`
  231. padding: ${space(1)};
  232. &:hover {
  233. cursor: pointer;
  234. }
  235. `;
  236. const StyledIconGrabbable = styled(IconGrabbable)`
  237. &:hover {
  238. cursor: grab;
  239. }
  240. `;
  241. const WidgetTitle = styled(HeaderTitle)`
  242. ${overflowEllipsis};
  243. `;
  244. const WidgetHeader = styled('div')`
  245. padding: ${space(2)} ${space(3)} 0 ${space(3)};
  246. width: 100%;
  247. display: flex;
  248. justify-content: space-between;
  249. `;
  250. const ContextWrapper = styled('div')`
  251. margin-left: ${space(1)};
  252. `;