widgetBuilderSlideout.tsx 9.7 KB

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