newWidgetBuilder.tsx 16 KB

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