widgetCard.tsx 7.9 KB

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