widgetCard.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import React, {MouseEvent} 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 classNames from 'classnames';
  7. import {Location} from 'history';
  8. import isEqual from 'lodash/isEqual';
  9. import {Client} from 'app/api';
  10. import {HeaderTitle} from 'app/components/charts/styles';
  11. import DropdownMenu from 'app/components/dropdownMenu';
  12. import ErrorBoundary from 'app/components/errorBoundary';
  13. import MenuItem from 'app/components/menuItem';
  14. import {isSelectionEqual} from 'app/components/organizations/globalSelectionHeader/utils';
  15. import {Panel} from 'app/components/panels';
  16. import Placeholder from 'app/components/placeholder';
  17. import {IconDelete, IconEdit, IconEllipsis, IconGrabbable} from 'app/icons';
  18. import {t} from 'app/locale';
  19. import overflowEllipsis from 'app/styles/overflowEllipsis';
  20. import space from 'app/styles/space';
  21. import {GlobalSelection, Organization} from 'app/types';
  22. import {trackAnalyticsEvent} from 'app/utils/analytics';
  23. import withApi from 'app/utils/withApi';
  24. import withGlobalSelection from 'app/utils/withGlobalSelection';
  25. import withOrganization from 'app/utils/withOrganization';
  26. import {Widget} from './types';
  27. import {eventViewFromWidget} from './utils';
  28. import WidgetCardChart from './widgetCardChart';
  29. import WidgetQueries from './widgetQueries';
  30. type DraggableProps = Pick<ReturnType<typeof useSortable>, 'attributes' | 'listeners'>;
  31. type Props = ReactRouter.WithRouterProps & {
  32. api: Client;
  33. organization: Organization;
  34. location: Location;
  35. isEditing: boolean;
  36. widget: Widget;
  37. selection: GlobalSelection;
  38. onDelete: () => void;
  39. onEdit: () => void;
  40. isSorting: boolean;
  41. currentWidgetDragging: boolean;
  42. showContextMenu?: boolean;
  43. hideToolbar?: boolean;
  44. draggableProps?: DraggableProps;
  45. renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
  46. };
  47. class WidgetCard extends React.Component<Props> {
  48. shouldComponentUpdate(nextProps: Props): boolean {
  49. if (
  50. !isEqual(nextProps.widget, this.props.widget) ||
  51. !isSelectionEqual(nextProps.selection, this.props.selection) ||
  52. this.props.isEditing !== nextProps.isEditing ||
  53. this.props.isSorting !== nextProps.isSorting ||
  54. this.props.hideToolbar !== nextProps.hideToolbar
  55. ) {
  56. return true;
  57. }
  58. return false;
  59. }
  60. renderToolbar() {
  61. const {onEdit, onDelete, draggableProps, hideToolbar, isEditing} = this.props;
  62. if (!isEditing) {
  63. return null;
  64. }
  65. return (
  66. <ToolbarPanel>
  67. <IconContainer style={{visibility: hideToolbar ? 'hidden' : 'visible'}}>
  68. <IconClick>
  69. <StyledIconGrabbable
  70. color="textColor"
  71. {...draggableProps?.listeners}
  72. {...draggableProps?.attributes}
  73. />
  74. </IconClick>
  75. <IconClick
  76. data-test-id="widget-edit"
  77. onClick={() => {
  78. onEdit();
  79. }}
  80. >
  81. <IconEdit color="textColor" />
  82. </IconClick>
  83. <IconClick
  84. data-test-id="widget-delete"
  85. onClick={() => {
  86. onDelete();
  87. }}
  88. >
  89. <IconDelete color="textColor" />
  90. </IconClick>
  91. </IconContainer>
  92. </ToolbarPanel>
  93. );
  94. }
  95. renderContextMenu() {
  96. const {widget, selection, organization, showContextMenu} = this.props;
  97. if (!showContextMenu) {
  98. return null;
  99. }
  100. const menuOptions: React.ReactNode[] = [];
  101. if (
  102. widget.displayType === 'table' &&
  103. organization.features.includes('discover-basic')
  104. ) {
  105. // Open table widget in Discover
  106. if (widget.queries.length) {
  107. // We expect Table widgets to have only one query.
  108. const query = widget.queries[0];
  109. const eventView = eventViewFromWidget(widget.title, query, selection);
  110. menuOptions.push(
  111. <MenuItem
  112. key="open-discover"
  113. onClick={event => {
  114. event.preventDefault();
  115. trackAnalyticsEvent({
  116. eventKey: 'dashboards2.tablewidget.open_in_discover',
  117. eventName: 'Dashboards2: Table Widget - Open in Discover',
  118. organization_id: parseInt(this.props.organization.id, 10),
  119. });
  120. browserHistory.push(eventView.getResultsViewUrlTarget(organization.slug));
  121. }}
  122. >
  123. {t('Open in Discover')}
  124. </MenuItem>
  125. );
  126. }
  127. }
  128. if (!menuOptions.length) {
  129. return null;
  130. }
  131. return (
  132. <ContextWrapper>
  133. <ContextMenu>{menuOptions}</ContextMenu>
  134. </ContextWrapper>
  135. );
  136. }
  137. render() {
  138. const {
  139. widget,
  140. api,
  141. organization,
  142. selection,
  143. renderErrorMessage,
  144. location,
  145. 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(
  190. withOrganization(withGlobalSelection(ReactRouter.withRouter(WidgetCard)))
  191. );
  192. const ErrorCard = styled(Placeholder)`
  193. display: flex;
  194. align-items: center;
  195. justify-content: center;
  196. background-color: ${p => p.theme.alert.error.backgroundLight};
  197. border: 1px solid ${p => p.theme.alert.error.border};
  198. color: ${p => p.theme.alert.error.textLight};
  199. border-radius: ${p => p.theme.borderRadius};
  200. margin-bottom: ${space(2)};
  201. `;
  202. const StyledPanel = styled(Panel, {
  203. shouldForwardProp: prop => prop !== 'isDragging',
  204. })<{
  205. isDragging: boolean;
  206. }>`
  207. margin: 0;
  208. visibility: ${p => (p.isDragging ? 'hidden' : 'visible')};
  209. /* If a panel overflows due to a long title stretch its grid sibling */
  210. height: 100%;
  211. min-height: 96px;
  212. `;
  213. const ToolbarPanel = styled('div')`
  214. position: absolute;
  215. top: 0;
  216. left: 0;
  217. z-index: 1;
  218. width: 100%;
  219. height: 100%;
  220. display: flex;
  221. justify-content: flex-end;
  222. align-items: flex-start;
  223. background-color: ${p => p.theme.overlayBackgroundAlpha};
  224. border-radius: ${p => p.theme.borderRadius};
  225. `;
  226. const IconContainer = styled('div')`
  227. display: flex;
  228. margin: 10px ${space(2)};
  229. touch-action: none;
  230. `;
  231. const IconClick = styled('div')`
  232. padding: ${space(1)};
  233. &:hover {
  234. cursor: pointer;
  235. }
  236. `;
  237. const StyledIconGrabbable = styled(IconGrabbable)`
  238. &:hover {
  239. cursor: grab;
  240. }
  241. `;
  242. const WidgetTitle = styled(HeaderTitle)`
  243. ${overflowEllipsis};
  244. `;
  245. const WidgetHeader = styled('div')`
  246. padding: ${space(2)} ${space(3)} 0 ${space(3)};
  247. width: 100%;
  248. display: flex;
  249. justify-content: space-between;
  250. `;
  251. const ContextMenu = ({children}) => (
  252. <DropdownMenu>
  253. {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
  254. const topLevelCx = classNames('dropdown', {
  255. 'anchor-right': true,
  256. open: isOpen,
  257. });
  258. return (
  259. <MoreOptions
  260. {...getRootProps({
  261. className: topLevelCx,
  262. })}
  263. >
  264. <DropdownTarget
  265. {...getActorProps<HTMLDivElement>({
  266. onClick: (event: MouseEvent) => {
  267. event.stopPropagation();
  268. event.preventDefault();
  269. },
  270. })}
  271. >
  272. <IconEllipsis data-test-id="context-menu" size="md" />
  273. </DropdownTarget>
  274. {isOpen && (
  275. <ul {...getMenuProps({})} className={classNames('dropdown-menu')}>
  276. {children}
  277. </ul>
  278. )}
  279. </MoreOptions>
  280. );
  281. }}
  282. </DropdownMenu>
  283. );
  284. const MoreOptions = styled('span')`
  285. display: flex;
  286. color: ${p => p.theme.textColor};
  287. `;
  288. const DropdownTarget = styled('div')`
  289. display: flex;
  290. cursor: pointer;
  291. `;
  292. const ContextWrapper = styled('div')`
  293. margin-left: ${space(1)};
  294. `;