addToDashboardModal.tsx 10 KB

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