widgetBuilderSlideout.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import {Fragment, useEffect, useRef, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import {Button} from 'sentry/components/button';
  6. import {openConfirmModal} from 'sentry/components/confirm';
  7. import SlideOverPanel from 'sentry/components/slideOverPanel';
  8. import {IconClose} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {trackAnalytics} from 'sentry/utils/analytics';
  12. import {WidgetBuilderVersion} from 'sentry/utils/analytics/dashboardsAnalyticsEvents';
  13. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  14. import useMedia from 'sentry/utils/useMedia';
  15. import useOrganization from 'sentry/utils/useOrganization';
  16. import {useValidateWidgetQuery} from 'sentry/views/dashboards/hooks/useValidateWidget';
  17. import {
  18. type DashboardDetails,
  19. type DashboardFilters,
  20. DisplayType,
  21. type Widget,
  22. } from 'sentry/views/dashboards/types';
  23. import WidgetBuilderDatasetSelector from 'sentry/views/dashboards/widgetBuilder/components/datasetSelector';
  24. import WidgetBuilderFilterBar from 'sentry/views/dashboards/widgetBuilder/components/filtersBar';
  25. import WidgetBuilderGroupBySelector from 'sentry/views/dashboards/widgetBuilder/components/groupBySelector';
  26. import WidgetBuilderNameAndDescription from 'sentry/views/dashboards/widgetBuilder/components/nameAndDescFields';
  27. import {
  28. type ThresholdMetaState,
  29. WidgetPreviewContainer,
  30. } from 'sentry/views/dashboards/widgetBuilder/components/newWidgetBuilder';
  31. import WidgetBuilderQueryFilterBuilder from 'sentry/views/dashboards/widgetBuilder/components/queryFilterBuilder';
  32. import SaveButton from 'sentry/views/dashboards/widgetBuilder/components/saveButton';
  33. import WidgetBuilderSortBySelector from 'sentry/views/dashboards/widgetBuilder/components/sortBySelector';
  34. import ThresholdsSection from 'sentry/views/dashboards/widgetBuilder/components/thresholds';
  35. import WidgetBuilderTypeSelector from 'sentry/views/dashboards/widgetBuilder/components/typeSelector';
  36. import Visualize from 'sentry/views/dashboards/widgetBuilder/components/visualize';
  37. import WidgetTemplatesList from 'sentry/views/dashboards/widgetBuilder/components/widgetTemplatesList';
  38. import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
  39. import useIsEditingWidget from 'sentry/views/dashboards/widgetBuilder/hooks/useIsEditingWidget';
  40. import {convertBuilderStateToWidget} from 'sentry/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget';
  41. type WidgetBuilderSlideoutProps = {
  42. dashboard: DashboardDetails;
  43. dashboardFilters: DashboardFilters;
  44. isOpen: boolean;
  45. isWidgetInvalid: boolean;
  46. onClose: () => void;
  47. onQueryConditionChange: (valid: boolean) => void;
  48. onSave: ({index, widget}: {index: number; widget: Widget}) => void;
  49. openWidgetTemplates: boolean;
  50. setIsPreviewDraggable: (draggable: boolean) => void;
  51. setOpenWidgetTemplates: (openWidgetTemplates: boolean) => void;
  52. onDataFetched?: (tableData: TableDataWithTitle[]) => void;
  53. thresholdMetaState?: ThresholdMetaState;
  54. };
  55. function WidgetBuilderSlideout({
  56. isOpen,
  57. onClose,
  58. onSave,
  59. onQueryConditionChange,
  60. dashboard,
  61. dashboardFilters,
  62. setIsPreviewDraggable,
  63. isWidgetInvalid,
  64. openWidgetTemplates,
  65. setOpenWidgetTemplates,
  66. onDataFetched,
  67. thresholdMetaState,
  68. }: WidgetBuilderSlideoutProps) {
  69. const organization = useOrganization();
  70. const {state} = useWidgetBuilderContext();
  71. const [initialState] = useState(state);
  72. const [error, setError] = useState<Record<string, any>>({});
  73. const theme = useTheme();
  74. const isEditing = useIsEditingWidget();
  75. const validatedWidgetResponse = useValidateWidgetQuery(
  76. convertBuilderStateToWidget(state)
  77. );
  78. useEffect(() => {
  79. if (!openWidgetTemplates) {
  80. trackAnalytics('dashboards_views.widget_builder.opened', {
  81. builder_version: WidgetBuilderVersion.SLIDEOUT,
  82. new_widget: !isEditing,
  83. organization,
  84. });
  85. }
  86. // Ignore isEditing because it won't change during the
  87. // useful lifetime of the widget builder, but it
  88. // flickers when an edited widget is saved.
  89. // eslint-disable-next-line react-hooks/exhaustive-deps
  90. }, [openWidgetTemplates, organization]);
  91. const title = openWidgetTemplates
  92. ? t('Add from Widget Library')
  93. : isEditing
  94. ? t('Edit Widget')
  95. : t('Create Custom Widget');
  96. const isChartWidget =
  97. state.displayType !== DisplayType.BIG_NUMBER &&
  98. state.displayType !== DisplayType.TABLE;
  99. const customPreviewRef = useRef<HTMLDivElement>(null);
  100. const templatesPreviewRef = useRef<HTMLDivElement>(null);
  101. const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.small})`);
  102. const showSortByStep =
  103. (isChartWidget && state.fields && state.fields.length > 0) ||
  104. state.displayType === DisplayType.TABLE;
  105. useEffect(() => {
  106. const observer = new IntersectionObserver(
  107. ([entry]) => {
  108. setIsPreviewDraggable(!entry!.isIntersecting);
  109. },
  110. {threshold: 0}
  111. );
  112. // need two different refs to account for preview when customizing templates
  113. if (customPreviewRef.current) {
  114. observer.observe(customPreviewRef.current);
  115. }
  116. if (templatesPreviewRef.current) {
  117. observer.observe(templatesPreviewRef.current);
  118. }
  119. return () => observer.disconnect();
  120. }, [setIsPreviewDraggable, openWidgetTemplates]);
  121. return (
  122. <SlideOverPanel
  123. collapsed={!isOpen}
  124. slidePosition="left"
  125. data-test-id="widget-slideout"
  126. >
  127. <SlideoutHeaderWrapper>
  128. <SlideoutTitle>{title}</SlideoutTitle>
  129. <CloseButton
  130. priority="link"
  131. size="zero"
  132. borderless
  133. aria-label={t('Close Widget Builder')}
  134. icon={<IconClose size="sm" />}
  135. onClick={() => {
  136. openConfirmModal({
  137. bypass: isEqual(initialState, state),
  138. message: t('You have unsaved changes. Are you sure you want to leave?'),
  139. priority: 'danger',
  140. onConfirm: onClose,
  141. });
  142. }}
  143. >
  144. {t('Close')}
  145. </CloseButton>
  146. </SlideoutHeaderWrapper>
  147. <SlideoutBodyWrapper>
  148. {!openWidgetTemplates ? (
  149. <Fragment>
  150. <Section>
  151. <WidgetBuilderFilterBar />
  152. </Section>
  153. <Section>
  154. <WidgetBuilderDatasetSelector />
  155. </Section>
  156. <Section>
  157. <WidgetBuilderTypeSelector error={error} setError={setError} />
  158. </Section>
  159. <div ref={customPreviewRef}>
  160. {isSmallScreen && (
  161. <Section>
  162. <WidgetPreviewContainer
  163. dashboard={dashboard}
  164. dashboardFilters={dashboardFilters}
  165. isWidgetInvalid={isWidgetInvalid}
  166. onDataFetched={onDataFetched}
  167. openWidgetTemplates={openWidgetTemplates}
  168. />
  169. </Section>
  170. )}
  171. </div>
  172. <Section>
  173. <Visualize error={error} setError={setError} />
  174. </Section>
  175. <Section>
  176. <WidgetBuilderQueryFilterBuilder
  177. onQueryConditionChange={onQueryConditionChange}
  178. validatedWidgetResponse={validatedWidgetResponse}
  179. />
  180. </Section>
  181. {state.displayType === DisplayType.BIG_NUMBER && (
  182. <Section>
  183. <ThresholdsSection
  184. dataType={thresholdMetaState?.dataType}
  185. dataUnit={thresholdMetaState?.dataUnit}
  186. />
  187. </Section>
  188. )}
  189. {isChartWidget && (
  190. <Section>
  191. <WidgetBuilderGroupBySelector
  192. validatedWidgetResponse={validatedWidgetResponse}
  193. />
  194. </Section>
  195. )}
  196. {showSortByStep && (
  197. <Section>
  198. <WidgetBuilderSortBySelector />
  199. </Section>
  200. )}
  201. <Section>
  202. <WidgetBuilderNameAndDescription error={error} setError={setError} />
  203. </Section>
  204. <SaveButton isEditing={isEditing} onSave={onSave} setError={setError} />
  205. </Fragment>
  206. ) : (
  207. <Fragment>
  208. <div ref={templatesPreviewRef}>
  209. {isSmallScreen && (
  210. <Section>
  211. <WidgetPreviewContainer
  212. dashboard={dashboard}
  213. dashboardFilters={dashboardFilters}
  214. isWidgetInvalid={isWidgetInvalid}
  215. onDataFetched={onDataFetched}
  216. openWidgetTemplates={openWidgetTemplates}
  217. />
  218. </Section>
  219. )}
  220. </div>
  221. <WidgetTemplatesList
  222. onSave={onSave}
  223. setOpenWidgetTemplates={setOpenWidgetTemplates}
  224. setIsPreviewDraggable={setIsPreviewDraggable}
  225. />
  226. </Fragment>
  227. )}
  228. </SlideoutBodyWrapper>
  229. </SlideOverPanel>
  230. );
  231. }
  232. export default WidgetBuilderSlideout;
  233. const CloseButton = styled(Button)`
  234. color: ${p => p.theme.gray300};
  235. height: fit-content;
  236. &:hover {
  237. color: ${p => p.theme.gray400};
  238. }
  239. z-index: 100;
  240. `;
  241. const SlideoutTitle = styled('h5')`
  242. margin: 0;
  243. `;
  244. const SlideoutHeaderWrapper = styled('div')`
  245. padding: ${space(3)} ${space(4)};
  246. display: flex;
  247. align-items: center;
  248. justify-content: space-between;
  249. border-bottom: 1px solid ${p => p.theme.border};
  250. `;
  251. const SlideoutBodyWrapper = styled('div')`
  252. padding: ${space(4)};
  253. `;
  254. const Section = styled('div')`
  255. margin-bottom: ${space(4)};
  256. `;