addToDashboardModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import {useEffect, useState} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {Location, Query} from 'history';
  6. import {
  7. fetchDashboard,
  8. fetchDashboards,
  9. updateDashboard,
  10. } from 'sentry/actionCreators/dashboards';
  11. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  12. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  13. import {Button} from 'sentry/components/button';
  14. import ButtonBar from 'sentry/components/buttonBar';
  15. import SelectControl from 'sentry/components/forms/controls/selectControl';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {DateString, PageFilters, SelectValue} from 'sentry/types/core';
  19. import type {Organization} from 'sentry/types/organization';
  20. import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
  21. import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  22. import useApi from 'sentry/utils/useApi';
  23. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  24. import type {
  25. DashboardDetails,
  26. DashboardListItem,
  27. Widget,
  28. } from 'sentry/views/dashboards/types';
  29. import {DisplayType, MAX_WIDGETS, WidgetType} from 'sentry/views/dashboards/types';
  30. import {
  31. eventViewFromWidget,
  32. getDashboardFiltersFromURL,
  33. getSavedFiltersAsPageFilters,
  34. getSavedPageFilters,
  35. } from 'sentry/views/dashboards/utils';
  36. import {NEW_DASHBOARD_ID} from 'sentry/views/dashboards/widgetBuilder/utils';
  37. import WidgetCard from 'sentry/views/dashboards/widgetCard';
  38. import {OrganizationContext} from 'sentry/views/organizationContext';
  39. import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
  40. type WidgetAsQueryParams = Query<{
  41. defaultTableColumns: string[];
  42. defaultTitle: string;
  43. defaultWidgetQuery: string;
  44. displayType: DisplayType;
  45. environment: string[];
  46. project: number[];
  47. source: string;
  48. end?: DateString;
  49. start?: DateString;
  50. statsPeriod?: string | null;
  51. }>;
  52. type AddToDashboardModalActions =
  53. | 'add-and-open-dashboard'
  54. | 'add-and-stay-on-current-page'
  55. | 'open-in-widget-builder';
  56. export type AddToDashboardModalProps = {
  57. location: Location;
  58. organization: Organization;
  59. router: InjectedRouter;
  60. selection: PageFilters;
  61. widget: Widget;
  62. widgetAsQueryParams: WidgetAsQueryParams;
  63. actions?: AddToDashboardModalActions[];
  64. allowCreateNewDashboard?: boolean;
  65. };
  66. type Props = ModalRenderProps & AddToDashboardModalProps;
  67. const SELECT_DASHBOARD_MESSAGE = t('Select a dashboard');
  68. const DEFAULT_ACTIONS: AddToDashboardModalActions[] = [
  69. 'add-and-stay-on-current-page',
  70. 'open-in-widget-builder',
  71. ];
  72. function AddToDashboardModal({
  73. Header,
  74. Body,
  75. Footer,
  76. closeModal,
  77. location,
  78. organization,
  79. router,
  80. selection,
  81. widget,
  82. widgetAsQueryParams,
  83. actions = DEFAULT_ACTIONS,
  84. allowCreateNewDashboard = true,
  85. }: Props) {
  86. const api = useApi();
  87. const [dashboards, setDashboards] = useState<DashboardListItem[] | null>(null);
  88. const [selectedDashboard, setSelectedDashboard] = useState<DashboardDetails | null>(
  89. null
  90. );
  91. const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(null);
  92. useEffect(() => {
  93. // Track mounted state so we dont call setState on unmounted components
  94. let unmounted = false;
  95. fetchDashboards(api, organization.slug).then(response => {
  96. // If component has unmounted, dont set state
  97. if (unmounted) {
  98. return;
  99. }
  100. setDashboards(response);
  101. });
  102. return () => {
  103. unmounted = true;
  104. };
  105. }, [api, organization.slug]);
  106. useEffect(() => {
  107. // Track mounted state so we dont call setState on unmounted components
  108. let unmounted = false;
  109. if (selectedDashboardId === NEW_DASHBOARD_ID || selectedDashboardId === null) {
  110. setSelectedDashboard(null);
  111. } else {
  112. fetchDashboard(api, organization.slug, selectedDashboardId).then(response => {
  113. // If component has unmounted, dont set state
  114. if (unmounted) {
  115. return;
  116. }
  117. setSelectedDashboard(response);
  118. });
  119. }
  120. return () => {
  121. unmounted = true;
  122. };
  123. }, [api, organization.slug, selectedDashboardId]);
  124. function goToDashboard(page: 'builder' | 'preview') {
  125. const dashboardsPath =
  126. selectedDashboardId === NEW_DASHBOARD_ID
  127. ? `/organizations/${organization.slug}/dashboards/new/`
  128. : `/organizations/${organization.slug}/dashboard/${selectedDashboardId}/`;
  129. const pathname = page === 'builder' ? `${dashboardsPath}widget/new/` : dashboardsPath;
  130. router.push(
  131. normalizeUrl({
  132. pathname,
  133. query: {
  134. ...widgetAsQueryParams,
  135. ...(selectedDashboard ? getSavedPageFilters(selectedDashboard) : {}),
  136. },
  137. })
  138. );
  139. closeModal();
  140. }
  141. async function handleAddWidget() {
  142. if (selectedDashboard === null) {
  143. return;
  144. }
  145. let orderby = widget.queries[0].orderby;
  146. if (!(DisplayType.AREA && widget.queries[0].columns.length)) {
  147. orderby = ''; // Clear orderby if its not a top n visualization.
  148. }
  149. const query = widget.queries[0];
  150. const title =
  151. // Metric widgets have their default title derived from the query
  152. widget.title === '' && widget.widgetType !== WidgetType.METRICS
  153. ? t('All Events')
  154. : widget.title;
  155. const newWidget = {
  156. ...widget,
  157. title,
  158. queries: [{...query, orderby}],
  159. };
  160. const newDashboard = {
  161. ...selectedDashboard,
  162. widgets: [...selectedDashboard.widgets, newWidget],
  163. };
  164. await updateDashboard(api, organization.slug, newDashboard);
  165. }
  166. async function handleAddAndStayOnCurrentPage() {
  167. await handleAddWidget();
  168. closeModal();
  169. addSuccessMessage(t('Successfully added widget to dashboard'));
  170. }
  171. async function handleAddAndOpenDashboard() {
  172. await handleAddWidget();
  173. goToDashboard('preview');
  174. }
  175. const canSubmit = selectedDashboardId !== null;
  176. return (
  177. <OrganizationContext.Provider value={organization}>
  178. <Header closeButton>
  179. <h4>{t('Add to Dashboard')}</h4>
  180. </Header>
  181. <Body>
  182. <Wrapper>
  183. <SelectControl
  184. disabled={dashboards === null}
  185. menuPlacement="auto"
  186. name="dashboard"
  187. placeholder={t('Select Dashboard')}
  188. value={selectedDashboardId}
  189. options={
  190. dashboards && [
  191. allowCreateNewDashboard && {
  192. label: t('+ Create New Dashboard'),
  193. value: 'new',
  194. },
  195. ...dashboards.map(({title, id, widgetDisplay}) => ({
  196. label: title,
  197. value: id,
  198. disabled: widgetDisplay.length >= MAX_WIDGETS,
  199. tooltip:
  200. widgetDisplay.length >= MAX_WIDGETS &&
  201. tct('Max widgets ([maxWidgets]) per dashboard reached.', {
  202. maxWidgets: MAX_WIDGETS,
  203. }),
  204. tooltipOptions: {position: 'right'},
  205. })),
  206. ]
  207. }
  208. onChange={(option: SelectValue<string>) => {
  209. if (option.disabled) {
  210. return;
  211. }
  212. setSelectedDashboardId(option.value);
  213. }}
  214. />
  215. </Wrapper>
  216. <Wrapper>
  217. {t(
  218. 'Any conflicting filters from this query will be overridden by Dashboard filters. This is a preview of how the widget will appear in your dashboard.'
  219. )}
  220. </Wrapper>
  221. <MetricsCardinalityProvider organization={organization} location={location}>
  222. <MetricsDataSwitcher
  223. organization={organization}
  224. eventView={eventViewFromWidget(widget.title, widget.queries[0], selection)}
  225. location={location}
  226. hideLoadingIndicator
  227. >
  228. {metricsDataSide => (
  229. <MEPSettingProvider
  230. location={location}
  231. forceTransactions={metricsDataSide.forceTransactionsOnly}
  232. >
  233. <WidgetCard
  234. organization={organization}
  235. isEditingDashboard={false}
  236. showContextMenu={false}
  237. widgetLimitReached={false}
  238. selection={
  239. selectedDashboard
  240. ? getSavedFiltersAsPageFilters(selectedDashboard)
  241. : selection
  242. }
  243. dashboardFilters={
  244. getDashboardFiltersFromURL(location) ?? selectedDashboard?.filters
  245. }
  246. widget={widget}
  247. showStoredAlert
  248. />
  249. </MEPSettingProvider>
  250. )}
  251. </MetricsDataSwitcher>
  252. </MetricsCardinalityProvider>
  253. </Body>
  254. <Footer>
  255. <StyledButtonBar gap={1.5}>
  256. {actions.includes('add-and-stay-on-current-page') && (
  257. <Button
  258. onClick={handleAddAndStayOnCurrentPage}
  259. disabled={!canSubmit || selectedDashboardId === NEW_DASHBOARD_ID}
  260. title={canSubmit ? undefined : SELECT_DASHBOARD_MESSAGE}
  261. >
  262. {t('Add + Stay on this Page')}
  263. </Button>
  264. )}
  265. {actions.includes('add-and-open-dashboard') && (
  266. <Button
  267. onClick={handleAddAndOpenDashboard}
  268. disabled={!canSubmit}
  269. title={canSubmit ? undefined : SELECT_DASHBOARD_MESSAGE}
  270. >
  271. {t('Add + Open Dashboard')}
  272. </Button>
  273. )}
  274. {actions.includes('open-in-widget-builder') && (
  275. <Button
  276. priority="primary"
  277. onClick={() => goToDashboard('builder')}
  278. disabled={!canSubmit}
  279. title={canSubmit ? undefined : SELECT_DASHBOARD_MESSAGE}
  280. >
  281. {t('Open in Widget Builder')}
  282. </Button>
  283. )}
  284. </StyledButtonBar>
  285. </Footer>
  286. </OrganizationContext.Provider>
  287. );
  288. }
  289. export default AddToDashboardModal;
  290. const Wrapper = styled('div')`
  291. margin-bottom: ${space(2)};
  292. `;
  293. const StyledButtonBar = styled(ButtonBar)`
  294. @media (max-width: ${props => props.theme.breakpoints.small}) {
  295. grid-template-rows: repeat(2, 1fr);
  296. gap: ${space(1.5)};
  297. width: 100%;
  298. > button {
  299. width: 100%;
  300. }
  301. }
  302. `;
  303. export const modalCss = css`
  304. max-width: 700px;
  305. margin: 70px auto;
  306. `;