newWidgetBuilder.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import {type CSSProperties, Fragment, useCallback, useEffect, useState} from 'react';
  2. import {closestCorners, DndContext, useDraggable, useDroppable} from '@dnd-kit/core';
  3. import {css, useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {AnimatePresence, motion} from 'framer-motion';
  6. import cloneDeep from 'lodash/cloneDeep';
  7. import omit from 'lodash/omit';
  8. import {t} from 'sentry/locale';
  9. import PreferencesStore from 'sentry/stores/preferencesStore';
  10. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  11. import {space} from 'sentry/styles/space';
  12. import {CustomMeasurementsProvider} from 'sentry/utils/customMeasurements/customMeasurementsProvider';
  13. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  14. import EventView from 'sentry/utils/discover/eventView';
  15. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  16. import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
  17. import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  18. import {useLocation} from 'sentry/utils/useLocation';
  19. import useMedia from 'sentry/utils/useMedia';
  20. import useOrganization from 'sentry/utils/useOrganization';
  21. import usePageFilters from 'sentry/utils/usePageFilters';
  22. import {
  23. type DashboardDetails,
  24. type DashboardFilters,
  25. DisplayType,
  26. type Widget,
  27. } from 'sentry/views/dashboards/types';
  28. import {
  29. DEFAULT_WIDGET_DRAG_POSITIONING,
  30. DRAGGABLE_PREVIEW_HEIGHT_PX,
  31. DRAGGABLE_PREVIEW_WIDTH_PX,
  32. PREVIEW_HEIGHT_PX,
  33. SIDEBAR_HEIGHT,
  34. snapPreviewToCorners,
  35. WIDGET_PREVIEW_DRAG_ID,
  36. type WidgetDragPositioning,
  37. } from 'sentry/views/dashboards/widgetBuilder/components/common/draggableUtils';
  38. import WidgetBuilderSlideout from 'sentry/views/dashboards/widgetBuilder/components/widgetBuilderSlideout';
  39. import WidgetPreview from 'sentry/views/dashboards/widgetBuilder/components/widgetPreview';
  40. import {
  41. useWidgetBuilderContext,
  42. WidgetBuilderProvider,
  43. } from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
  44. import {DashboardsMEPProvider} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
  45. import {SpanTagsProvider} from 'sentry/views/explore/contexts/spanTagsContext';
  46. import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
  47. export interface ThresholdMetaState {
  48. dataType?: string;
  49. dataUnit?: string;
  50. }
  51. type WidgetBuilderV2Props = {
  52. dashboard: DashboardDetails;
  53. dashboardFilters: DashboardFilters;
  54. isOpen: boolean;
  55. onClose: () => void;
  56. onSave: ({index, widget}: {index: number; widget: Widget}) => void;
  57. openWidgetTemplates: boolean;
  58. setOpenWidgetTemplates: (openWidgetTemplates: boolean) => void;
  59. };
  60. function WidgetBuilderV2({
  61. isOpen,
  62. onClose,
  63. onSave,
  64. dashboardFilters,
  65. dashboard,
  66. setOpenWidgetTemplates,
  67. openWidgetTemplates,
  68. }: WidgetBuilderV2Props) {
  69. const organization = useOrganization();
  70. const {selection} = usePageFilters();
  71. const [queryConditionsValid, setQueryConditionsValid] = useState<boolean>(true);
  72. const theme = useTheme();
  73. const [isPreviewDraggable, setIsPreviewDraggable] = useState(false);
  74. const [thresholdMetaState, setThresholdMetaState] = useState<ThresholdMetaState>({});
  75. const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.small})`);
  76. const [translate, setTranslate] = useState<WidgetDragPositioning>(
  77. DEFAULT_WIDGET_DRAG_POSITIONING
  78. );
  79. const handleDragEnd = ({over}: any) => {
  80. setTranslate(snapPreviewToCorners(over));
  81. };
  82. const handleDragMove = ({delta}: any) => {
  83. setTranslate(previousTranslate => ({
  84. ...previousTranslate,
  85. initialTranslate: previousTranslate.initialTranslate,
  86. translate: {
  87. x: previousTranslate.initialTranslate.x + delta.x,
  88. y: previousTranslate.initialTranslate.y + delta.y,
  89. },
  90. }));
  91. };
  92. const handleWidgetDataFetched = useCallback(
  93. (tableData: TableDataWithTitle[]) => {
  94. const tableMeta = {...tableData[0]!.meta};
  95. const keys = Object.keys(tableMeta);
  96. const field = keys[0]!;
  97. const dataType = tableMeta[field];
  98. const dataUnit = tableMeta.units?.[field];
  99. const newState = cloneDeep(thresholdMetaState);
  100. newState.dataType = dataType;
  101. newState.dataUnit = dataUnit;
  102. setThresholdMetaState(newState);
  103. },
  104. [thresholdMetaState]
  105. );
  106. // reset the drag position when the draggable preview is not visible
  107. useEffect(() => {
  108. if (!isPreviewDraggable) {
  109. setTranslate(DEFAULT_WIDGET_DRAG_POSITIONING);
  110. }
  111. }, [isPreviewDraggable]);
  112. const preferences = useLegacyStore(PreferencesStore);
  113. const hasNewNav = organization?.features.includes('navigation-sidebar-v2');
  114. const sidebarCollapsed = hasNewNav ? true : !!preferences.collapsed;
  115. return (
  116. <Fragment>
  117. {isOpen && <Backdrop style={{opacity: 0.5, pointerEvents: 'auto'}} />}
  118. <AnimatePresence>
  119. {isOpen && (
  120. <WidgetBuilderProvider>
  121. <CustomMeasurementsProvider organization={organization} selection={selection}>
  122. <SpanTagsProvider
  123. dataset={DiscoverDatasets.SPANS_EAP}
  124. enabled={organization.features.includes('dashboards-eap')}
  125. >
  126. <ContainerWithoutSidebar sidebarCollapsed={sidebarCollapsed}>
  127. <WidgetBuilderContainer>
  128. <SlideoutContainer>
  129. <WidgetBuilderSlideout
  130. isOpen={isOpen}
  131. onClose={() => {
  132. onClose();
  133. setTranslate(DEFAULT_WIDGET_DRAG_POSITIONING);
  134. }}
  135. onSave={onSave}
  136. onQueryConditionChange={setQueryConditionsValid}
  137. dashboard={dashboard}
  138. dashboardFilters={dashboardFilters}
  139. setIsPreviewDraggable={setIsPreviewDraggable}
  140. isWidgetInvalid={!queryConditionsValid}
  141. openWidgetTemplates={openWidgetTemplates}
  142. setOpenWidgetTemplates={setOpenWidgetTemplates}
  143. onDataFetched={handleWidgetDataFetched}
  144. thresholdMetaState={thresholdMetaState}
  145. />
  146. </SlideoutContainer>
  147. {(!isSmallScreen || isPreviewDraggable) && (
  148. <DndContext
  149. onDragEnd={handleDragEnd}
  150. onDragMove={handleDragMove}
  151. collisionDetection={closestCorners}
  152. >
  153. <SurroundingWidgetContainer>
  154. <WidgetPreviewContainer
  155. dashboardFilters={dashboardFilters}
  156. dashboard={dashboard}
  157. dragPosition={translate}
  158. isDraggable={isPreviewDraggable}
  159. isWidgetInvalid={!queryConditionsValid}
  160. onDataFetched={handleWidgetDataFetched}
  161. openWidgetTemplates={openWidgetTemplates}
  162. />
  163. </SurroundingWidgetContainer>
  164. </DndContext>
  165. )}
  166. </WidgetBuilderContainer>
  167. </ContainerWithoutSidebar>
  168. </SpanTagsProvider>
  169. </CustomMeasurementsProvider>
  170. </WidgetBuilderProvider>
  171. )}
  172. </AnimatePresence>
  173. </Fragment>
  174. );
  175. }
  176. export default WidgetBuilderV2;
  177. export function WidgetPreviewContainer({
  178. dashboardFilters,
  179. dashboard,
  180. isWidgetInvalid,
  181. dragPosition,
  182. isDraggable,
  183. onDataFetched,
  184. openWidgetTemplates,
  185. }: {
  186. dashboard: DashboardDetails;
  187. dashboardFilters: DashboardFilters;
  188. isWidgetInvalid: boolean;
  189. dragPosition?: WidgetDragPositioning;
  190. isDraggable?: boolean;
  191. onDataFetched?: (tableData: TableDataWithTitle[]) => void;
  192. openWidgetTemplates?: boolean;
  193. }) {
  194. const {state} = useWidgetBuilderContext();
  195. const organization = useOrganization();
  196. const location = useLocation();
  197. const theme = useTheme();
  198. const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.small})`);
  199. // if small screen and draggable, enable dragging
  200. const isDragEnabled = isSmallScreen && isDraggable;
  201. const {attributes, listeners, setNodeRef, isDragging} = useDraggable({
  202. id: WIDGET_PREVIEW_DRAG_ID,
  203. disabled: !isDragEnabled,
  204. // May need to add 'handle' prop if we want to drag the preview by a specific area
  205. });
  206. const {translate, top, left} = dragPosition ?? {};
  207. const draggableStyle: CSSProperties = {
  208. transform: isDragEnabled
  209. ? `translate3d(${isDragging ? translate?.x : 0}px, ${isDragging ? translate?.y : 0}px, 0)`
  210. : undefined,
  211. top: isDragEnabled ? top ?? 0 : undefined,
  212. left: isDragEnabled ? left ?? 0 : undefined,
  213. opacity: isDragging ? 0.5 : 1,
  214. zIndex: isDragEnabled
  215. ? theme.zIndex.modal
  216. : isSmallScreen
  217. ? theme.zIndex.initial
  218. : // if not responsive, set z-index to default in styled component
  219. undefined,
  220. cursor: isDragEnabled ? 'grab' : undefined,
  221. margin: isDragEnabled ? '0' : undefined,
  222. alignSelf: isDragEnabled ? 'flex-start' : undefined,
  223. position: isDragEnabled ? 'fixed' : undefined,
  224. };
  225. // check if the state is in the url because the state variable has default values
  226. const hasUrlParams =
  227. Object.keys(
  228. omit(location.query, [
  229. 'environment',
  230. 'project',
  231. 'release',
  232. 'start',
  233. 'end',
  234. 'statsPeriod',
  235. ])
  236. ).length > 0;
  237. const getPreviewHeight = () => {
  238. if (isDragEnabled) {
  239. return DRAGGABLE_PREVIEW_HEIGHT_PX;
  240. }
  241. // if none of the widget templates are selected
  242. if (openWidgetTemplates && !hasUrlParams) {
  243. return PREVIEW_HEIGHT_PX;
  244. }
  245. if (state.displayType === DisplayType.TABLE) {
  246. return 'auto';
  247. }
  248. if (state.displayType === DisplayType.BIG_NUMBER && !isSmallScreen) {
  249. return '20vw';
  250. }
  251. return PREVIEW_HEIGHT_PX;
  252. };
  253. return (
  254. <DashboardsMEPProvider>
  255. <MetricsCardinalityProvider organization={organization} location={location}>
  256. <MetricsDataSwitcher
  257. organization={organization}
  258. location={location}
  259. hideLoadingIndicator
  260. eventView={EventView.fromLocation(location)}
  261. >
  262. {metricsDataSide => (
  263. <MEPSettingProvider
  264. location={location}
  265. forceTransactions={metricsDataSide.forceTransactionsOnly}
  266. >
  267. {isDragEnabled && <DroppablePreviewContainer />}
  268. <DraggableWidgetContainer
  269. ref={setNodeRef}
  270. id={WIDGET_PREVIEW_DRAG_ID}
  271. style={draggableStyle}
  272. aria-label={t('Draggable Widget Preview')}
  273. {...attributes}
  274. {...listeners}
  275. >
  276. {!isSmallScreen && (
  277. <WidgetPreviewTitle
  278. initial={{opacity: 0, x: '50%', y: 0}}
  279. animate={{opacity: 1, x: 0, y: 0}}
  280. exit={{opacity: 0, x: '50%', y: 0}}
  281. transition={{
  282. type: 'spring',
  283. stiffness: 500,
  284. damping: 50,
  285. }}
  286. >
  287. {t('Widget Preview')}
  288. </WidgetPreviewTitle>
  289. )}
  290. <SampleWidgetCard
  291. initial={{opacity: 0, x: '50%', y: 0}}
  292. animate={{opacity: 1, x: 0, y: 0}}
  293. exit={{opacity: 0, x: '50%', y: 0}}
  294. transition={{
  295. type: 'spring',
  296. stiffness: 500,
  297. damping: 50,
  298. }}
  299. style={{
  300. width: isDragEnabled ? DRAGGABLE_PREVIEW_WIDTH_PX : undefined,
  301. height: getPreviewHeight(),
  302. outline: isDragEnabled
  303. ? `${space(1)} solid ${theme.border}`
  304. : undefined,
  305. }}
  306. >
  307. {openWidgetTemplates && !hasUrlParams ? (
  308. <WidgetPreviewPlaceholder>
  309. <h6 style={{margin: 0}}>{t('Widget Title')}</h6>
  310. <TemplateWidgetPreviewPlaceholder>
  311. <p style={{margin: 0}}>{t('Select a widget to preview')}</p>
  312. </TemplateWidgetPreviewPlaceholder>
  313. </WidgetPreviewPlaceholder>
  314. ) : (
  315. <WidgetPreview
  316. dashboardFilters={dashboardFilters}
  317. dashboard={dashboard}
  318. isWidgetInvalid={isWidgetInvalid}
  319. onDataFetched={onDataFetched}
  320. shouldForceDescriptionTooltip={!isSmallScreen}
  321. />
  322. )}
  323. </SampleWidgetCard>
  324. </DraggableWidgetContainer>
  325. </MEPSettingProvider>
  326. )}
  327. </MetricsDataSwitcher>
  328. </MetricsCardinalityProvider>
  329. </DashboardsMEPProvider>
  330. );
  331. }
  332. function DroppablePreviewContainer() {
  333. const containers = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
  334. return (
  335. <DroppableGrid>
  336. {containers.map(id => (
  337. <Droppable key={id} id={id} />
  338. ))}
  339. </DroppableGrid>
  340. );
  341. }
  342. function Droppable({id}: {id: string}) {
  343. const {setNodeRef} = useDroppable({
  344. id,
  345. });
  346. return <div ref={setNodeRef} id={id} />;
  347. }
  348. const fullPageCss = css`
  349. position: fixed;
  350. top: 0;
  351. right: 0;
  352. bottom: 0;
  353. left: 0;
  354. `;
  355. const Backdrop = styled('div')`
  356. ${fullPageCss};
  357. z-index: ${p => p.theme.zIndex.widgetBuilderDrawer};
  358. background: ${p => p.theme.black};
  359. will-change: opacity;
  360. transition: opacity 200ms;
  361. pointer-events: none;
  362. opacity: 0;
  363. `;
  364. const SampleWidgetCard = styled(motion.div)`
  365. width: 100%;
  366. min-width: 100%;
  367. border: 1px dashed ${p => p.theme.gray300};
  368. border-radius: ${p => p.theme.borderRadius};
  369. background-color: ${p => p.theme.background};
  370. z-index: ${p => p.theme.zIndex.initial};
  371. position: relative;
  372. @media (min-width: ${p => p.theme.breakpoints.small}) {
  373. width: 40vw;
  374. min-width: 300px;
  375. z-index: ${p => p.theme.zIndex.modal};
  376. cursor: auto;
  377. }
  378. @media (max-width: ${p => p.theme.breakpoints.large}) and (min-width: ${p =>
  379. p.theme.breakpoints.medium}) {
  380. width: 30vw;
  381. min-width: 100px;
  382. }
  383. `;
  384. const DraggableWidgetContainer = styled(`div`)`
  385. align-content: center;
  386. z-index: ${p => p.theme.zIndex.initial};
  387. position: relative;
  388. margin: auto;
  389. cursor: auto;
  390. @media (min-width: ${p => p.theme.breakpoints.small}) {
  391. z-index: ${p => p.theme.zIndex.modal};
  392. transform: none;
  393. cursor: auto;
  394. }
  395. `;
  396. const ContainerWithoutSidebar = styled('div')<{sidebarCollapsed: boolean}>`
  397. z-index: ${p => p.theme.zIndex.widgetBuilderDrawer};
  398. position: fixed;
  399. top: 0;
  400. left: ${p =>
  401. p.sidebarCollapsed ? p.theme.sidebar.collapsedWidth : p.theme.sidebar.expandedWidth};
  402. right: 0;
  403. bottom: 0;
  404. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  405. left: 0;
  406. top: ${p => p.theme.sidebar.mobileHeight};
  407. }
  408. `;
  409. const WidgetBuilderContainer = styled('div')`
  410. z-index: ${p => p.theme.zIndex.widgetBuilderDrawer};
  411. display: flex;
  412. align-items: center;
  413. position: absolute;
  414. inset: 0;
  415. `;
  416. const DroppableGrid = styled('div')`
  417. display: grid;
  418. grid-template-columns: 1fr 1fr;
  419. grid-template-rows: 1fr 1fr;
  420. position: fixed;
  421. gap: ${space(4)};
  422. margin: ${space(2)};
  423. top: ${SIDEBAR_HEIGHT}px;
  424. right: ${space(2)};
  425. bottom: ${space(2)};
  426. left: 0;
  427. `;
  428. const TemplateWidgetPreviewPlaceholder = styled('div')`
  429. display: flex;
  430. flex-direction: column;
  431. align-items: center;
  432. justify-content: center;
  433. width: 100%;
  434. height: 95%;
  435. color: ${p => p.theme.subText};
  436. font-style: italic;
  437. font-size: ${p => p.theme.fontSizeMedium};
  438. font-weight: ${p => p.theme.fontWeightNormal};
  439. `;
  440. const WidgetPreviewPlaceholder = styled('div')`
  441. width: 100%;
  442. height: 100%;
  443. padding: ${space(2)};
  444. `;
  445. const SlideoutContainer = styled('div')`
  446. height: 100%;
  447. `;
  448. const SurroundingWidgetContainer = styled('div')`
  449. width: 100%;
  450. height: 100%;
  451. display: flex;
  452. justify-content: center;
  453. align-items: center;
  454. `;
  455. const WidgetPreviewTitle = styled(motion.h5)`
  456. margin-bottom: ${space(1)};
  457. margin-left: ${space(1)};
  458. color: ${p => p.theme.white};
  459. font-weight: ${p => p.theme.fontWeightBold};
  460. `;